# 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) opts_hash.delete(:usb_pid_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 } 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 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("-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("-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 (, 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 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 ) end ctx.check_raster(data, width_dots: width, height_dots: height) 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) 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