use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use uuid::Uuid;
use super::code128;
use super::qr::{encode as qr_encode, tables::Ecc};
use super::render::{Drawing, parse_colour};
use crate::pipeline::model::NodeCapability;
use crate::pipeline::nodes::shared::limits::{MAX_RASTER_SIDE, choice, raster, whole, within};
use crate::pipeline::nodes::shared::project_store::{OnConflict, on_conflict_flag, open_store, store_fields, store_flag, target_key};
use crate::pipeline::nodes::shared::util::{filename_stem, metadata_scope, with_answer};
use crate::pipeline::{
NodeDefinition, PipelineError,
model::{DslFlag, DslFlagKind, LayoutItem, NodeExample, NodeFieldDef, NodeFieldType, SelectOptionDef},
nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
use crate::platform::services::PlatformService;
pub const NODE_KIND: &str = "fs.barcode.render";
const INPUT_PIN_IN: &str = "in";
const OUTPUT_PIN_OUT: &str = "out";
pub const CODE: &str = "FW_NODE_FS_BARCODE_RENDER";
pub const CONFIG_CODE: &str = "FW_NODE_FS_BARCODE_RENDER_CONFIG";
const TEXT_CODE: &str = "FW_NODE_FS_BARCODE_RENDER_TEXT";
pub const SIZE_CODE: &str = "FW_NODE_FS_BARCODE_RENDER_SIZE";
const SYMBOLOGIES: &[&str] = &["qr", "code128"];
const FORMATS: &[&str] = &["svg", "png"];
const ECC_LEVELS: &[&str] = &["L", "M", "Q", "H"];
const DEFAULT_FOLDER: &str = "barcodes";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub text: Value,
#[serde(default)]
pub symbology: String,
#[serde(default)]
pub format: String,
#[serde(default)]
pub width: Value,
#[serde(default)]
pub height: Value,
#[serde(default)]
pub margin: Value,
#[serde(default)]
pub color: String,
#[serde(default)]
pub background: String,
#[serde(default)]
pub ecc: String,
#[serde(default)]
pub store: Option<String>,
#[serde(default)]
pub folder: Value,
#[serde(default)]
pub filename: Value,
#[serde(default)]
pub path: Value,
#[serde(default)]
pub on_conflict: Option<String>,
}
struct Symbology {
name: &'static str,
width: u32,
margin: u32,
max_margin: u32,
}
const QR: Symbology = Symbology { name: "qr", width: 256, margin: 4, max_margin: 16 };
const CODE128: Symbology = Symbology { name: "code128", width: 300, margin: 10, max_margin: 40 };
const DEFAULT_BAR_HEIGHT: u32 = 80;
fn flag(name: &str, key: &str, description: &str, value: &str) -> DslFlag {
DslFlag {
flag: name.to_string(),
config_key: key.to_string(),
description: description.to_string(),
kind: DslFlagKind::Scalar,
value: value.to_string(),
..Default::default()
}
}
fn field(name: &str, label: &str, help: &str) -> NodeFieldDef {
NodeFieldDef { name: name.to_string(), label: label.to_string(), field_type: NodeFieldType::Text, help: Some(help.to_string()), ..Default::default() }
}
fn select(name: &str, label: &str, help: &str, options: &[(&str, &str)]) -> NodeFieldDef {
NodeFieldDef {
field_type: NodeFieldType::Select,
options: options.iter().map(|(v, l)| SelectOptionDef { value: v.to_string(), label: l.to_string() }).collect(),
..field(name, label, help)
}
}
fn words(list: &[&str]) -> Vec<String> {
list.iter().map(|word| word.to_string()).collect()
}
pub fn definition() -> NodeDefinition {
let answer = |r: &str, size: u64, symbology: &str, width: u32, height: u32| {
json!({ "__zf_type": "file_ref", "backend": "zebfs", "store": "local", "ref": r, "filename": r.rsplit('/').next().unwrap_or(r), "mime": "image/svg+xml", "kind": "image", "size": size, "sha256": "sha256:…", "lifecycle": "durable", "origin": NODE_KIND, "trust": "generated", "symbology": symbology, "format": "svg", "width": width, "height": height })
};
NodeDefinition {
kind: NODE_KIND.to_string(),
capabilities: vec![NodeCapability::Filesystem],
title: "Barcode".to_string(),
description: "Draw a code a scanner reads for `--text` and store it as `--format svg|png` (default svg). \
`--symbology qr` (default) carries any text — a URL, a number — with `--ecc L|M|Q|H` (default M) trading size for damage tolerance; \
`--symbology code128` is a linear barcode for printable ASCII (a ticket or order number), digit runs packed two to a bar, \
`--height` its bar height (default 80). `--width` is the target width in px (qr 256, code128 300); `--margin` the light border \
in modules (qr 4, code128 10). Writes under `--folder` (default `barcodes`) as a generated name, as `--filename`, or at an exact \
`--path`; a named file that exists is an error unless `--on-conflict` says otherwise. Adds `barcode` — the durable FileRef \
(`ref`, `store`, `size`, …, `origin: fs.barcode.render`) with `symbology`, `format`, `width` and `height` in px — and keeps the rest of the payload."
.to_string(),
input_schema: json!({ "type": "object", "description": "Any payload; it is kept and `barcode` is added." }),
output_schema: json!({
"type": "object",
"properties": { "barcode": {
"type": "object",
"description": "The durable FileRef of the stored code, with what was drawn.",
"properties": {
"ref": { "type": "string" },
"store": { "type": "string" },
"origin": { "type": "string", "const": NODE_KIND },
"symbology": { "type": "string", "enum": SYMBOLOGIES },
"format": { "type": "string", "enum": FORMATS },
"width": { "type": "integer", "description": "Pixels, quiet zone included" },
"height": { "type": "integer", "description": "Pixels, quiet zone included" }
}
} }
}),
input_pins: vec![INPUT_PIN_IN.to_string()],
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
script_available: false,
script_bridge: None,
config_schema: Default::default(),
dsl_flags: vec![
DslFlag { required: true, ..flag("--text", "text", "What the code carries; code128 takes printable ASCII only. Usually a {{ }} expression.", "text") },
DslFlag { choices: words(SYMBOLOGIES), ..flag("--symbology", "symbology", "The code: qr (default) or code128.", "") },
DslFlag { choices: words(FORMATS), ..flag("--format", "format", "svg (default) or png.", "") },
flag("--width", "width", "Target width in px (default: qr 256, code128 300).", "number"),
flag("--height", "height", "code128 bar height in px (default 80); a QR Code is square and refuses it.", "number"),
flag("--margin", "margin", "Light border in modules (default: qr 4, code128 10).", "number"),
flag("--color", "color", "Dark colour, #rgb or #rrggbb (default #000000).", "text"),
flag("--background", "background", "Light colour, #rgb or #rrggbb (default #ffffff).", "text"),
DslFlag { choices: words(ECC_LEVELS), ..flag("--ecc", "ecc", "QR error correction (default M); code128 refuses it.", "") },
DslFlag { value: "text".to_string(), ..store_flag() },
flag("--folder", "folder", "Destination folder (default: barcodes).", "text"),
flag("--filename", "filename", "Destination name without extension (default: a generated name).", "text"),
flag("--path", "path", "Exact destination key; overrides --folder and --filename.", "text"),
DslFlag { choices: words(&["error", "skip", "overwrite"]), ..on_conflict_flag(OnConflict::Error) },
],
fields: vec![
field("text", "Text", "What the code carries: a URL, a number, any text (code128: printable ASCII)."),
NodeFieldDef { default_value: Some(json!("qr")), ..select("symbology", "Symbology", "QR for any text; Code 128 for a short ASCII number under a QR.", &[("qr", "QR Code"), ("code128", "Code 128")]) },
NodeFieldDef { default_value: Some(json!("svg")), ..select("format", "Format", "SVG stays sharp at any size; PNG for places that need pixels.", &[("svg", "SVG"), ("png", "PNG")]) },
field("width", "Width (px)", "Target width (default: QR 256, Code 128 300)."),
field("height", "Bar height (px)", "Code 128 only (default 80)."),
field("margin", "Margin (modules)", "Light border (default: QR 4, Code 128 10); scanners need it."),
NodeFieldDef { default_value: Some(json!("#000000")), ..field("color", "Colour", "#rgb or #rrggbb.") },
NodeFieldDef { default_value: Some(json!("#ffffff")), ..field("background", "Background", "#rgb or #rrggbb.") },
select("ecc", "Error correction", "QR only: how much of the code may be damaged and still read.", &[("", "Default (M)"), ("L", "L — 7%"), ("M", "M — 15%"), ("Q", "Q — 25%"), ("H", "H — 30%")]),
field("folder", "Folder", "Destination folder (default: barcodes)."),
field("filename", "Filename", "Without extension (default: a generated name)."),
field("path", "Path", "Exact destination key; overrides folder and filename."),
]
.into_iter()
.chain(store_fields(OnConflict::Error))
.collect(),
layout: ["text", "symbology", "format", "width", "height", "margin", "color", "background", "ecc", "folder", "filename", "path", "store", "on_conflict"]
.iter()
.map(|name| LayoutItem::Field(name.to_string()))
.collect(),
examples: vec![
NodeExample::dsl(
"A verification link for a certificate",
"fs.barcode.render --text \"{{ 'https://example.com/certificates/' + input.number }}\" --folder certificates/qr --filename \"{{ input.number }}\"",
)
.input(json!({ "number": "CERT-2026-0412" }))
.output(json!({ "number": "CERT-2026-0412", "barcode": answer("certificates/qr/CERT-2026-0412.svg", 2841, "qr", 232, 232) })),
NodeExample::dsl(
"A ticket number as a linear barcode",
"fs.barcode.render --symbology code128 --text \"{{ input.ticket }}\" --folder tickets/codes --filename \"{{ input.ticket }}\"",
)
.input(json!({ "ticket": "T-000481" }))
.output(json!({ "ticket": "T-000481", "barcode": answer("tickets/codes/T-000481.svg", 1904, "code128", 278, 100) })),
],
..Default::default()
}
}
fn text_of(value: &Value, flag: &str, code: &'static str) -> Result<String, PipelineError> {
match value {
Value::Null => Ok(String::new()),
Value::String(s) => Ok(s.clone()),
Value::Number(n) => Ok(n.to_string()),
Value::Bool(b) => Ok(b.to_string()),
_ => Err(PipelineError::new(code, format!("{flag} must be text, not {value}"))),
}
}
struct Plan {
symbology: &'static Symbology,
text: String,
format: &'static str,
width: u32,
bar_height: u32,
margin: usize,
ecc: Ecc,
dark: [u8; 3],
light: [u8; 3],
}
impl Plan {
fn read(c: &Config) -> Result<Self, PipelineError> {
let symbology = match choice(&c.symbology, SYMBOLOGIES, "qr", "--symbology", CONFIG_CODE)? {
"code128" => &CODE128,
_ => &QR,
};
let text = text_of(&c.text, "--text", TEXT_CODE)?;
if text.is_empty() {
return Err(PipelineError::new(TEXT_CODE, "--text is empty: say what the code should carry"));
}
let format = choice(&c.format, FORMATS, "svg", "--format", CONFIG_CODE)?;
let ecc = choice(&c.ecc, ECC_LEVELS, "M", "--ecc", CONFIG_CODE)?;
let height = whole(&c.height, "--height", CONFIG_CODE)?;
if symbology.name == "qr" && height.is_some() {
return Err(PipelineError::new(CONFIG_CODE, "--height is for code128: a QR Code is square, so set --width only"));
}
if symbology.name == "code128" && !c.ecc.trim().is_empty() {
return Err(PipelineError::new(CONFIG_CODE, "--ecc is for qr: Code 128 has a check symbol, not error correction levels"));
}
let width = within(whole(&c.width, "--width", CONFIG_CODE)?.unwrap_or(symbology.width), 1, MAX_RASTER_SIDE, "--width", CONFIG_CODE)?;
let bar_height = within(height.unwrap_or(DEFAULT_BAR_HEIGHT), 1, MAX_RASTER_SIDE, "--height", CONFIG_CODE)?;
let margin = within(whole(&c.margin, "--margin", CONFIG_CODE)?.unwrap_or(symbology.margin), 0, symbology.max_margin, "--margin", CONFIG_CODE)?;
let colour = |raw: &str, default: &str, flag: &str| {
let raw = if raw.trim().is_empty() { default } else { raw };
parse_colour(raw).ok_or_else(|| PipelineError::new(CONFIG_CODE, format!("{flag} '{raw}' is not #rgb or #rrggbb")))
};
Ok(Self {
symbology,
text,
format,
width,
bar_height,
margin: margin as usize,
ecc: Ecc::parse(ecc).expect("a listed level"),
dark: colour(&c.color, "#000000", "--color")?,
light: colour(&c.background, "#ffffff", "--background")?,
})
}
fn modules(&self) -> Result<(Vec<Vec<bool>>, String), PipelineError> {
if self.symbology.name == "code128" {
let values = code128::encode::values(&self.text).map_err(|bad| {
PipelineError::new(TEXT_CODE, format!("'{bad}' is not printable ASCII; Code 128 cannot carry it — use --symbology qr"))
})?;
return Ok((vec![code128::encode::modules(&values)], format!("{} symbols", values.len())));
}
let symbol = qr_encode::encode(&self.text, self.ecc).ok_or_else(|| {
PipelineError::new(TEXT_CODE, format!("{} bytes do not fit a QR Code at ecc {}; shorten the text or lower --ecc", self.text.len(), self.ecc.letter()))
})?;
Ok((symbol.modules, format!("v{} {} mask {}", symbol.version, symbol.ecc.letter(), symbol.mask)))
}
}
pub struct Node {
config: Config,
platform: Arc<PlatformService>,
}
impl Node {
pub fn new(config: Config, platform: Arc<PlatformService>) -> Result<Self, PipelineError> {
Ok(Self { config, platform })
}
}
#[async_trait]
impl NodeHandler for Node {
fn kind(&self) -> &'static str {
NODE_KIND
}
fn input_pins(&self) -> &'static [&'static str] {
&[INPUT_PIN_IN]
}
fn output_pins(&self) -> &'static [&'static str] {
&[OUTPUT_PIN_OUT]
}
async fn execute_async(&self, input: NodeExecutionInput) -> Result<NodeExecutionOutput, PipelineError> {
let (owner, project, ..) = metadata_scope(&input.metadata)?;
let c = &self.config;
let plan = Plan::read(c)?;
let (rows, facts) = plan.modules()?;
let columns = rows.first().map_or(0, Vec::len);
let module_px = (plan.width / (columns + 2 * plan.margin) as u32).max(1);
let row_px = if plan.symbology.name == "qr" { module_px } else { plan.bar_height };
let drawing = Drawing { rows: &rows, margin: plan.margin, module_px, row_px, dark: plan.dark, light: plan.light };
raster(drawing.width_px(), drawing.height_px(), SIZE_CODE)?;
let bytes = if plan.format == "svg" { drawing.svg().into_bytes() } else { drawing.png()? };
let folder = text_of(&c.folder, "--folder", CONFIG_CODE)?;
let folder = if folder.trim().is_empty() { DEFAULT_FOLDER.to_string() } else { folder };
let filename = text_of(&c.filename, "--filename", CONFIG_CODE)?;
let path = text_of(&c.path, "--path", CONFIG_CODE)?;
let stem = Some(filename_stem(&filename)).filter(|s| !s.is_empty()).unwrap_or_else(|| Uuid::new_v4().to_string());
let name = format!("{stem}.{}", plan.format);
let key = target_key(Some(path.as_str()).filter(|p| !p.trim().is_empty()), &folder, &name, CODE)?;
let store = open_store(&self.platform, owner, project, c.store.as_deref())?;
let mut barcode = if OnConflict::parse(c.on_conflict.as_deref(), OnConflict::Error, CODE)?.allows(&store.fs, &key, CODE)? {
store.fs.put(&key, &bytes).map_err(|e| PipelineError::new(CODE, format!("write '{key}': {e}")))?;
let mime = if plan.format == "svg" { "image/svg+xml" } else { "image/png" };
let leaf = key.rsplit('/').next().unwrap_or(&key);
store.file_ref(&key, leaf, mime, &bytes, NODE_KIND, "generated")
} else {
store.stored_ref(&key, NODE_KIND, "generated", CODE)?
};
if let Some(map) = barcode.as_object_mut() {
map.insert("symbology".into(), json!(plan.symbology.name));
map.insert("format".into(), json!(plan.format));
map.insert("width".into(), json!(drawing.width_px()));
map.insert("height".into(), json!(drawing.height_px()));
}
let trace = format!("node_kind={NODE_KIND} {} {facts} {}", plan.symbology.name, barcode["ref"]);
let payload = with_answer(&input.payload, json!({ "barcode": barcode }));
Ok(NodeExecutionOutput { output_pins: vec![OUTPUT_PIN_OUT.into()], payload, trace: vec![trace] })
}
}
#[cfg(test)]
mod tests;