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
This commit is contained in:
knb
2026-04-19 10:06:50 +09:00
parent d0e5846012
commit f1779b94f0
4 changed files with 164 additions and 2 deletions

View File

@@ -2,9 +2,13 @@
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"
@@ -289,12 +293,155 @@ module Libptouch
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.