Replace auto-cut toggle with --cut bit flags (default 011), wire flags through C/Ruby APIs, and document the new cut/debug-dump behavior in both READMEs. Made-with: Cursor
590 lines
20 KiB
Ruby
590 lines
20 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
require "json"
|
||
require "optparse"
|
||
require "rqrcode"
|
||
require "rexml/document"
|
||
require "tempfile"
|
||
require "yaml"
|
||
require "barby"
|
||
require "barby/barcode/code_128"
|
||
require "barby/outputter/svg_outputter"
|
||
|
||
require "libptouch"
|
||
|
||
module Libptouch
|
||
module Cli
|
||
# PNG/SVG を扱う ptouch-print 相当の CLI(1bit ラスター経路なし)。
|
||
module LabelPrint
|
||
module_function
|
||
|
||
def run(argv)
|
||
opts = parse(argv)
|
||
return 2 if opts.nil?
|
||
|
||
return run_version if opts[:version]
|
||
return run_help if opts[:help]
|
||
|
||
return run_media_info(opts) if opts[:media_info]
|
||
|
||
if opts[:status]
|
||
warn_unused_file_options(opts)
|
||
return run_status(opts)
|
||
end
|
||
|
||
if opts[:template] || opts[:data]
|
||
return usage_error("--template and --data must be used together") if opts[:template].to_s.empty? || opts[:data].to_s.empty?
|
||
return usage_error("-f and --template/--data cannot be used together") unless opts[:file].to_s.empty?
|
||
return run_template_print(opts)
|
||
end
|
||
|
||
return usage_error("-f is required (or use --template/--data, or --status)") if opts[:file].to_s.empty?
|
||
|
||
path = opts[:file]
|
||
kind = image_kind(path)
|
||
return usage_error("not a PNG/SVG file: #{path}") if kind.nil?
|
||
|
||
run_print(path, opts, kind)
|
||
end
|
||
|
||
def png_file?(path)
|
||
return true if path.downcase.end_with?(".png")
|
||
|
||
File.open(path, "rb") do |f|
|
||
sig = f.read(8)
|
||
sig == "\x89PNG\r\n\x1a\n".b
|
||
end
|
||
rescue Errno::ENOENT, Errno::EACCES => e
|
||
warn "open #{path}: #{e.message}"
|
||
false
|
||
end
|
||
|
||
def svg_file?(path)
|
||
path.downcase.end_with?(".svg")
|
||
end
|
||
|
||
def image_kind(path)
|
||
return :png if png_file?(path)
|
||
return :svg if svg_file?(path)
|
||
|
||
nil
|
||
end
|
||
|
||
def parse(argv)
|
||
opts_hash = default_cli_opts
|
||
build_cli_parser(opts_hash).parse!(argv)
|
||
return nil unless threshold_option_ok?(opts_hash)
|
||
return nil unless trim_right_option_ok?(opts_hash)
|
||
return nil unless usb_pid_option_ok?(opts_hash)
|
||
return nil if opts_hash[:cut_invalid]
|
||
|
||
opts_hash.delete(:usb_pid_invalid)
|
||
opts_hash.delete(:cut_invalid)
|
||
opts_hash
|
||
end
|
||
|
||
def default_cli_opts
|
||
{
|
||
file: nil,
|
||
template: nil,
|
||
data: nil,
|
||
threshold: nil,
|
||
usb_pid: nil,
|
||
usb_pid_invalid: false,
|
||
dry_run: false,
|
||
trim_right: nil,
|
||
media_info: false,
|
||
status: false,
|
||
version: false,
|
||
help: false,
|
||
cut_flags: Libptouch::RASTER_FLAGS_DEFAULT,
|
||
debug_dump: nil
|
||
}
|
||
end
|
||
|
||
def pid_option_description
|
||
p900 = Libptouch::USB_PID_PTP900W
|
||
p750 = Libptouch::USB_PID_PTP750W
|
||
p710 = Libptouch::USB_PID_PTP710BT
|
||
"USB 製品 ID(16 進可)。既定 P900W 0x#{p900.to_s(16)}; " \
|
||
"P750W 0x#{p750.to_s(16)}; P710BT 0x#{p710.to_s(16)}"
|
||
end
|
||
|
||
def apply_usb_pid_option(opts_hash, pid_str)
|
||
opts_hash[:usb_pid] = Integer(pid_str, 0)
|
||
rescue ArgumentError
|
||
warn "invalid --pid: #{pid_str.inspect}"
|
||
opts_hash[:usb_pid_invalid] = true
|
||
end
|
||
|
||
def threshold_option_ok?(opts_hash)
|
||
return true if opts_hash[:threshold].nil? || (0..255).cover?(opts_hash[:threshold])
|
||
|
||
warn "-t must be 0..255"
|
||
false
|
||
end
|
||
|
||
def trim_right_option_ok?(opts_hash)
|
||
v = opts_hash[:trim_right]
|
||
return true if v.nil? || v == :auto
|
||
return true if v.is_a?(Integer) && v >= 0
|
||
|
||
warn "--trim-right must be omitted, or >= 0"
|
||
false
|
||
end
|
||
|
||
def usb_pid_option_ok?(opts_hash)
|
||
return false if opts_hash[:usb_pid_invalid]
|
||
return true if opts_hash[:usb_pid].nil?
|
||
return true if opts_hash[:usb_pid].between?(1, 0xFFFF)
|
||
|
||
warn "-p/--pid must be 1..0xFFFF"
|
||
false
|
||
end
|
||
|
||
def apply_cut_option(opts_hash, bits)
|
||
unless bits.is_a?(String) && bits.match?(/\A[01]{3}\z/)
|
||
warn "--cut must be 3 bits (e.g. 010: auto/half/chain)"
|
||
opts_hash[:cut_invalid] = true
|
||
return
|
||
end
|
||
flags = 0
|
||
flags |= Libptouch::RASTER_FLAG_AUTO_CUT if bits[0] == "1"
|
||
flags |= Libptouch::RASTER_FLAG_HALF_CUT if bits[1] == "1"
|
||
flags |= Libptouch::RASTER_FLAG_CHAIN_PRINT if bits[2] == "1"
|
||
opts_hash[:cut_flags] = flags
|
||
end
|
||
|
||
def build_cli_parser(opts_hash)
|
||
OptionParser.new do |p|
|
||
p.banner = usage_banner
|
||
p.separator ""
|
||
p.on("-f", "--file PATH", "入力 PNG/SVG ファイル") { |v| opts_hash[:file] = v }
|
||
p.on("--template PATH", "差込用 SVG テンプレート") { |v| opts_hash[:template] = v }
|
||
p.on("--data PATH", "差込データ JSON/YAML ファイル") { |v| opts_hash[:data] = v }
|
||
p.on("-t", "--threshold N", Integer,
|
||
"しきい値 0–255(既定 #{Libptouch::PNG_DEFAULT_THRESHOLD}、PNG/SVG)") do |v|
|
||
opts_hash[:threshold] = v
|
||
end
|
||
p.on("-p", "--pid PID", pid_option_description) do |v|
|
||
apply_usb_pid_option(opts_hash, v)
|
||
end
|
||
p.on("--cut BITS", "3bit: [auto][half][chain]。既定 011") do |v|
|
||
apply_cut_option(opts_hash, v)
|
||
end
|
||
p.on("--debug-dump PATH", "デバッグ: 印字バイト列を PATH に保存(1 印刷ごとに上書き)") do |v|
|
||
opts_hash[:debug_dump] = v
|
||
end
|
||
p.on("-n", "--dry-run", "読み込みと検証のみ(USB なし)") { opts_hash[:dry_run] = true }
|
||
p.on("--trim-right[=DOTS]", Integer,
|
||
"右側空白を削減。DOTS省略時は左余白(失敗時 0)") do |v|
|
||
opts_hash[:trim_right] = v.nil? ? :auto : v
|
||
end
|
||
p.on("-M", "--media-info", "現在テープ情報(幅/DPI/余白)を JSON で表示して終了") do
|
||
opts_hash[:media_info] = true
|
||
end
|
||
p.on("-S", "--status", "ステータスを JSON で表示して終了") do
|
||
opts_hash[:status] = true
|
||
end
|
||
p.on("-V", "--version", "バージョンを表示して終了") { opts_hash[:version] = true }
|
||
p.on("-h", "--help", "このヘルプ") { opts_hash[:help] = true }
|
||
end
|
||
end
|
||
|
||
def usage_banner
|
||
<<~BANNER
|
||
Usage: ptouch-label [options]
|
||
|
||
PNG/SVG 対応。画像サイズを使用します(-w/-H はありません)。
|
||
--template/--data で SVG 差込印刷ができます。
|
||
SVG は現在テープ幅に自動フィットします(USB 必須)。
|
||
--trim-right[=DOTS] で右側空白を削減します(省略時は左余白基準)。
|
||
--status / --media-info のときは -f は不要です。
|
||
BANNER
|
||
end
|
||
|
||
def warn_unused_file_options(opts)
|
||
return unless opts[:file] || opts[:template] || opts[:data] || opts[:dry_run] || !opts[:trim_right].nil? || !opts[:threshold].nil?
|
||
|
||
warn "warning: options other than --status are ignored"
|
||
end
|
||
|
||
def run_version
|
||
puts "ptouch-label #{Libptouch::VERSION}"
|
||
0
|
||
end
|
||
|
||
def run_help
|
||
puts usage_banner
|
||
puts ""
|
||
puts parser_help_text
|
||
0
|
||
end
|
||
|
||
def parser_help_text
|
||
opts_hash = default_cli_opts
|
||
p = OptionParser.new do |parser|
|
||
parser.banner = "ptouch-label [options]"
|
||
parser.on("-f", "--file PATH", "入力 PNG/SVG ファイル") { |v| opts_hash[:file] = v }
|
||
parser.on("--template PATH", "差込用 SVG テンプレート") { |v| opts_hash[:template] = v }
|
||
parser.on("--data PATH", "差込データ JSON/YAML ファイル") { |v| opts_hash[:data] = v }
|
||
parser.on("-t", "--threshold N", Integer,
|
||
"しきい値 0–255(既定 #{Libptouch::PNG_DEFAULT_THRESHOLD}、PNG/SVG)") do |v|
|
||
opts_hash[:threshold] = v
|
||
end
|
||
parser.on("-p", "--pid PID", pid_option_description) do |v|
|
||
apply_usb_pid_option(opts_hash, v)
|
||
end
|
||
parser.on("--cut BITS", "3bit: [auto][half][chain]。既定 011") do |v|
|
||
apply_cut_option(opts_hash, v)
|
||
end
|
||
parser.on("--debug-dump PATH", "印字バイト列を PATH に保存") do |v|
|
||
opts_hash[:debug_dump] = v
|
||
end
|
||
parser.on("-n", "--dry-run", "読み込みと検証のみ(USB なし)") { opts_hash[:dry_run] = true }
|
||
parser.on("--trim-right[=DOTS]", Integer,
|
||
"右側空白を削減。DOTS省略時は左余白(失敗時 0)") do |v|
|
||
opts_hash[:trim_right] = v.nil? ? :auto : v
|
||
end
|
||
parser.on("-M", "--media-info", "現在テープ情報(幅/DPI/余白)を JSON で表示して終了") do
|
||
opts_hash[:media_info] = true
|
||
end
|
||
parser.on("-S", "--status", "ステータスを JSON で表示して終了") do
|
||
opts_hash[:status] = true
|
||
end
|
||
parser.on("-V", "--version", "バージョンを表示して終了") { opts_hash[:version] = true }
|
||
parser.on("-h", "--help", "このヘルプ") { opts_hash[:help] = true }
|
||
end
|
||
p.help
|
||
end
|
||
|
||
def open_usb_for_opts(ctx, opts)
|
||
if opts[:usb_pid]
|
||
ctx.open_usb_vid_pid(Libptouch::USB_VID_BROTHER, opts[:usb_pid])
|
||
else
|
||
ctx.open_usb
|
||
end
|
||
end
|
||
|
||
def run_template_print(opts)
|
||
svg_text = render_svg_template(opts[:template], load_merge_data(opts[:data]))
|
||
Tempfile.create(["ptouch-merge-", ".svg"]) do |tmp|
|
||
tmp.binmode
|
||
tmp.write(svg_text)
|
||
tmp.flush
|
||
run_print(tmp.path, opts, :svg)
|
||
end
|
||
rescue REXML::ParseException => e
|
||
warn "template parse error: #{e.message}"
|
||
1
|
||
rescue Errno::ENOENT, Errno::EACCES => e
|
||
warn e.message
|
||
1
|
||
rescue JSON::ParserError, Psych::SyntaxError => e
|
||
warn "data parse error: #{e.message}"
|
||
1
|
||
rescue ArgumentError => e
|
||
warn "data error: #{e.message}"
|
||
1
|
||
end
|
||
|
||
def load_merge_data(path)
|
||
text = File.read(path, encoding: "UTF-8")
|
||
ext = File.extname(path).downcase
|
||
parsed = if ext == ".json"
|
||
JSON.parse(text)
|
||
else
|
||
YAML.safe_load(text, permitted_classes: [], aliases: false)
|
||
end
|
||
unless parsed.is_a?(Hash)
|
||
raise ArgumentError, "data must be a key-value object/hash"
|
||
end
|
||
|
||
parsed.transform_keys(&:to_s)
|
||
end
|
||
|
||
def render_svg_template(path, data)
|
||
xml = File.read(path, encoding: "UTF-8")
|
||
doc = REXML::Document.new(xml)
|
||
doc.elements.each("//text[@data-field]") do |el|
|
||
# If descendants also have data-field (e.g. tspan placeholders),
|
||
# keep node structure and let element-level replacements handle them.
|
||
next if el.elements[".//*[@data-field]"]
|
||
|
||
key = el.attributes["data-field"].to_s
|
||
next unless data.key?(key)
|
||
|
||
replace_text_element_content(el, data[key].to_s)
|
||
end
|
||
doc.elements.each("//tspan[@data-field]") do |el|
|
||
key = el.attributes["data-field"].to_s
|
||
next unless data.key?(key)
|
||
|
||
replace_text_element_content(el, data[key].to_s)
|
||
end
|
||
replace_code_placeholders(doc, data)
|
||
out = +""
|
||
formatter = REXML::Formatters::Default.new
|
||
formatter.write(doc, out)
|
||
out
|
||
end
|
||
|
||
def replace_code_placeholders(doc, data)
|
||
doc.elements.each("//*[@data-field][@data-kb-placeholder]") do |el|
|
||
key = el.attributes["data-field"].to_s
|
||
next unless data.key?(key)
|
||
|
||
kind = el.attributes["data-kb-placeholder"].to_s.downcase
|
||
next unless %w[qr barcode].include?(kind)
|
||
|
||
x, y, width, height = read_box(el)
|
||
next if width <= 0 || height <= 0
|
||
|
||
raw_value = data[key].to_s
|
||
next if raw_value.empty?
|
||
|
||
replacement = build_code_svg_element(
|
||
kind: kind,
|
||
value: raw_value,
|
||
x: x,
|
||
y: y,
|
||
width: width,
|
||
height: height
|
||
)
|
||
el.parent&.insert_after(el, replacement)
|
||
el.parent&.delete(el)
|
||
end
|
||
end
|
||
|
||
def read_box(el)
|
||
x = el.attributes["x"].to_s.to_f
|
||
y = el.attributes["y"].to_s.to_f
|
||
width = el.attributes["width"].to_s.to_f
|
||
height = el.attributes["height"].to_s.to_f
|
||
[x, y, width, height]
|
||
end
|
||
|
||
def build_code_svg_element(kind:, value:, x:, y:, width:, height:)
|
||
svg_fragment =
|
||
if kind == "qr"
|
||
render_qr_svg(value)
|
||
else
|
||
render_barcode_svg(value)
|
||
end
|
||
fragment_doc = REXML::Document.new(strip_xml_declaration(svg_fragment))
|
||
svg_root = fragment_doc.root
|
||
svg_root = wrap_non_svg_root(svg_root) unless svg_root&.name == "svg"
|
||
|
||
if kind == "qr"
|
||
apply_qr_svg_box(svg_root, x: x, y: y, width: width, height: height)
|
||
else
|
||
apply_barcode_svg_box(svg_root, x: x, y: y, height: height)
|
||
end
|
||
svg_root
|
||
end
|
||
|
||
def apply_qr_svg_box(svg_root, x:, y:, width:, height:)
|
||
nat_w, nat_h = svg_natural_dimensions(svg_root)
|
||
if nat_w <= 0 || nat_h <= 0
|
||
nat_w = parse_length_attr(svg_root.attributes["width"])
|
||
nat_h = parse_length_attr(svg_root.attributes["height"])
|
||
end
|
||
raise ArgumentError, "QR SVG has no usable dimensions" if nat_w <= 0 || nat_h <= 0
|
||
|
||
svg_root.attributes["viewBox"] = "0 0 #{nat_w} #{nat_h}"
|
||
svg_root.attributes["x"] = x.to_s
|
||
svg_root.attributes["y"] = y.to_s
|
||
svg_root.attributes["width"] = width.to_s
|
||
svg_root.attributes["height"] = height.to_s
|
||
svg_root.attributes["preserveAspectRatio"] = "xMidYMid meet"
|
||
end
|
||
|
||
def apply_barcode_svg_box(svg_root, x:, y:, height:)
|
||
nat_w, nat_h = svg_natural_dimensions(svg_root)
|
||
if nat_w <= 0 || nat_h <= 0
|
||
nat_w = parse_length_attr(svg_root.attributes["width"])
|
||
nat_h = parse_length_attr(svg_root.attributes["height"])
|
||
end
|
||
raise ArgumentError, "barcode SVG has no usable dimensions" if nat_w <= 0 || nat_h <= 0
|
||
|
||
scale = height / nat_h
|
||
out_w = nat_w * scale
|
||
out_h = nat_h * scale
|
||
|
||
svg_root.attributes["viewBox"] = "0 0 #{nat_w} #{nat_h}"
|
||
svg_root.attributes["x"] = x.to_s
|
||
svg_root.attributes["y"] = y.to_s
|
||
svg_root.attributes["width"] = out_w.to_s
|
||
svg_root.attributes["height"] = out_h.to_s
|
||
svg_root.attributes["preserveAspectRatio"] = "xMidYMid meet"
|
||
end
|
||
|
||
def strip_xml_declaration(s)
|
||
s.to_s.sub(/\A<\?xml[^>]*\?>\s*/m, "")
|
||
end
|
||
|
||
def svg_natural_dimensions(svg_root)
|
||
vb = svg_root.attributes["viewBox"].to_s.strip
|
||
if (m = /\A\s*([-\d.eE+]+)\s+([-\d.eE+]+)\s+([-\d.eE+]+)\s+([-\d.eE+]+)\s*\z/.match(vb))
|
||
return [m[3].to_f, m[4].to_f]
|
||
end
|
||
|
||
[parse_length_attr(svg_root.attributes["width"]),
|
||
parse_length_attr(svg_root.attributes["height"])]
|
||
end
|
||
|
||
def parse_length_attr(str)
|
||
s = str.to_s.strip
|
||
return 0.0 if s.empty?
|
||
|
||
s = s.delete_suffix("px").strip
|
||
Float(s)
|
||
rescue ArgumentError
|
||
0.0
|
||
end
|
||
|
||
def render_qr_svg(value)
|
||
qrcode = RQRCode::QRCode.new(value)
|
||
qrcode.as_svg(
|
||
standalone: true,
|
||
use_path: true,
|
||
module_size: 1,
|
||
offset: 0,
|
||
color: "000",
|
||
shape_rendering: "crispEdges"
|
||
)
|
||
end
|
||
|
||
def render_barcode_svg(value)
|
||
barcode = Barby::Code128B.new(value)
|
||
Barby::SvgOutputter.new(barcode).to_svg(
|
||
xdim: 2,
|
||
height: 64,
|
||
margin: 0
|
||
)
|
||
end
|
||
|
||
def wrap_non_svg_root(root)
|
||
svg = REXML::Element.new("svg")
|
||
svg.add_namespace("http://www.w3.org/2000/svg")
|
||
svg.add_element(root)
|
||
svg
|
||
end
|
||
|
||
def replace_text_element_content(text_element, value)
|
||
# Remove all child nodes first so mixed content (<tspan>, text nodes, etc.)
|
||
# gets replaced consistently by merge data.
|
||
text_element.children.to_a.each { |child| text_element.delete(child) }
|
||
text_element.add(REXML::Text.new(value, true))
|
||
end
|
||
|
||
def run_status(opts)
|
||
ctx = nil
|
||
begin
|
||
ctx = Libptouch::Context.new
|
||
open_usb_for_opts(ctx, opts)
|
||
h = Libptouch.parse_status(ctx.status_bytes)
|
||
h.delete(:raw_bytes)
|
||
puts JSON.pretty_generate(h)
|
||
0
|
||
rescue Libptouch::Error => e
|
||
warn "get_status: #{e.message}"
|
||
1
|
||
ensure
|
||
ctx&.dispose
|
||
end
|
||
end
|
||
|
||
def run_media_info(opts)
|
||
warn_unused_file_options(opts)
|
||
ctx = nil
|
||
begin
|
||
ctx = Libptouch::Context.new
|
||
open_usb_for_opts(ctx, opts)
|
||
puts JSON.pretty_generate(ctx.current_media_info)
|
||
0
|
||
rescue Libptouch::Error => e
|
||
warn "media_info: #{e.message}"
|
||
1
|
||
ensure
|
||
ctx&.dispose
|
||
end
|
||
end
|
||
|
||
def run_print(path, opts, kind)
|
||
ctx = nil
|
||
begin
|
||
ctx = Libptouch::Context.new
|
||
ctx.debug_dump_path = opts[:debug_dump] if opts[:debug_dump]
|
||
usb_opened = false
|
||
threshold = opts[:threshold]
|
||
data, width, height = if kind == :svg
|
||
open_usb_for_opts(ctx, opts)
|
||
usb_opened = true
|
||
if threshold.nil?
|
||
ctx.svg_file_to_raster_fit_current_tape(path)
|
||
else
|
||
ctx.svg_file_to_raster_fit_current_tape(path, threshold: threshold)
|
||
end
|
||
elsif threshold.nil?
|
||
ctx.png_file_to_raster(path)
|
||
else
|
||
ctx.png_file_to_raster(path, threshold: threshold)
|
||
end
|
||
|
||
unless opts[:trim_right].nil?
|
||
trim_pad, usb_opened = resolve_trim_right_pad_dots(ctx, opts, usb_opened)
|
||
data, width, height = ctx.trim_right_blank_columns(
|
||
data,
|
||
width_dots: width,
|
||
height_dots: height,
|
||
right_padding_dots: trim_pad,
|
||
cut_flags: opts[:cut_flags]
|
||
)
|
||
end
|
||
|
||
ctx.check_raster(data, width_dots: width, height_dots: height,
|
||
cut_flags: opts[:cut_flags])
|
||
|
||
if opts[:dry_run]
|
||
puts "dry-run OK: #{data.bytesize} bytes, src #{width}x#{height} dots (print lengthxwidth #{width}x#{height})"
|
||
return 0
|
||
end
|
||
|
||
open_usb_for_opts(ctx, opts) if kind == :png && !usb_opened
|
||
ctx.print_raster(data, width_dots: width, height_dots: height,
|
||
cut_flags: opts[:cut_flags])
|
||
0
|
||
rescue Libptouch::Error => e
|
||
warn e.message
|
||
1
|
||
ensure
|
||
ctx&.dispose
|
||
end
|
||
end
|
||
|
||
def resolve_trim_right_pad_dots(ctx, opts, usb_opened)
|
||
trim = opts[:trim_right]
|
||
return [trim, usb_opened] if trim.is_a?(Integer)
|
||
|
||
begin
|
||
unless usb_opened
|
||
open_usb_for_opts(ctx, opts)
|
||
usb_opened = true
|
||
end
|
||
info = ctx.current_media_info
|
||
[Integer(info[:left_margin_dots] || 0), usb_opened]
|
||
rescue Libptouch::Error
|
||
[0, usb_opened]
|
||
end
|
||
end
|
||
|
||
def usage_error(msg)
|
||
warn msg
|
||
warn "(try ptouch-label --help)"
|
||
2
|
||
end
|
||
end
|
||
end
|
||
end
|