Files
ptouch_label/ruby/lib/libptouch/cli/label_print.rb
knb f1779b94f0 feat(ruby): QR/Barcode merge placeholders in SVG templates
Add data-kb-placeholder qr/barcode support with rqrcode and barby.
Scale QR to fit placeholder box (viewBox + meet) and barcode by height
with natural width. Declare barby and rqrcode runtime dependencies.

Made-with: Cursor
2026-04-19 10:06:50 +09:00

557 lines
18 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 相当の CLI1bit ラスター経路なし)。
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 製品 ID16 進可)。既定 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,
"しきい値 0255既定 #{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,
"しきい値 0255既定 #{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 (<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
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