Skip to main content

document_svg/document/
jdf.rs

1//! Bounded CIP4 Job Definition Format (JDF/JMF) workflow previews.
2//!
3//! JDF is an XML job-ticket and process-automation format. This adapter
4//! exposes node/resource structure without executing jobs, contacting devices,
5//! following URLs or opening linked files.
6
7use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::document::html::{HtmlBlock, render_blocks_to_pages};
11use crate::error::{Error, Result};
12use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
13use crate::table::{TableAlign, TableData};
14
15const MAX_JDF_BYTES: u64 = 128 * 1024 * 1024;
16const MAX_JDF_EVENTS: usize = 1_000_000;
17const MAX_JDF_NODES: usize = 500_000;
18const MAX_JDF_DEPTH: usize = 128;
19const MAX_JDF_TEXT_BYTES: usize = 32 * 1024 * 1024;
20const MAX_JDF_ROWS: usize = 200_000;
21const MAX_JDF_DISPLAY_BYTES: usize = 512;
22
23#[derive(Default)]
24struct Summary {
25    root: String,
26    version: String,
27    status: String,
28    jdf_nodes: usize,
29    resources: usize,
30    resource_pools: usize,
31    resource_links: usize,
32    products: usize,
33    processes: usize,
34    devices: usize,
35    media: usize,
36    layouts: usize,
37    run_lists: usize,
38    audits: usize,
39    files: usize,
40    urls: usize,
41    rows: Vec<Vec<String>>,
42}
43
44struct JdfPageSink<'a> {
45    inner: &'a mut dyn PageConsumer,
46    warnings: &'a [String],
47    source_format: &'static str,
48}
49
50impl PageConsumer for JdfPageSink<'_> {
51    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
52        page.source_format = self.source_format.into();
53        if page.title.is_empty() {
54            page.title = if self.source_format == "xjdf" {
55                "XJDF job ticket".into()
56            } else {
57                "JDF job ticket".into()
58            };
59        }
60        page.description = "JDF/JMF workflow metadata is rendered as bounded inert rows; jobs, devices, URLs and external files are not executed".into();
61        for warning in self.warnings {
62            page.warn(warning.clone());
63        }
64        self.inner.consume(page)
65    }
66}
67
68pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
69    let text = String::from_utf8_lossy(prefix).to_ascii_lowercase();
70    text.contains("cip4.org/jdfschema")
71        && (text.contains("<jdf") || text.contains("<jmf") || text.contains("<xjdf"))
72}
73
74pub(crate) fn looks_like_xjdf_prefix(prefix: &[u8]) -> bool {
75    let text = String::from_utf8_lossy(prefix).to_ascii_lowercase();
76    text.contains("cip4.org/jdfschema") && text.contains("<xjdf")
77}
78
79pub(crate) fn convert(
80    path: &Path,
81    options: &ConvertOptions,
82    sink: &mut dyn PageConsumer,
83) -> Result<Vec<String>> {
84    convert_inner(path, options, sink, false)
85}
86
87pub(crate) fn convert_xjdf(
88    path: &Path,
89    options: &ConvertOptions,
90    sink: &mut dyn PageConsumer,
91) -> Result<Vec<String>> {
92    convert_inner(path, options, sink, true)
93}
94
95fn convert_inner(
96    path: &Path,
97    options: &ConvertOptions,
98    sink: &mut dyn PageConsumer,
99    xjdf: bool,
100) -> Result<Vec<String>> {
101    let bytes = read_limited_file(
102        path,
103        options.max_input_bytes.min(MAX_JDF_BYTES),
104        "JDF input",
105    )?;
106    let root = parse_xml_tree(
107        &bytes,
108        &XmlLimits {
109            max_events: options.max_xml_events.min(MAX_JDF_EVENTS),
110            max_nodes: MAX_JDF_NODES,
111            max_depth: MAX_JDF_DEPTH,
112            max_text_bytes: MAX_JDF_TEXT_BYTES,
113        },
114        "JDF",
115    )?;
116    let root_matches = if xjdf {
117        root.name.eq_ignore_ascii_case("XJDF")
118    } else {
119        root.name.eq_ignore_ascii_case("JDF") || root.name.eq_ignore_ascii_case("JMF")
120    };
121    if !root_matches {
122        return Err(Error::InvalidInput(
123            if xjdf {
124                "XJDF document must have an XJDF root"
125            } else {
126                "JDF document must have a JDF or JMF root"
127            }
128            .into(),
129        ));
130    }
131    if !root.namespace.as_deref().is_some_and(|namespace| {
132        namespace
133            .to_ascii_lowercase()
134            .contains("cip4.org/jdfschema")
135    }) {
136        return Err(Error::InvalidInput(
137            "JDF root uses an unsupported namespace".into(),
138        ));
139    }
140    let mut summary = Summary {
141        root: root.name.clone(),
142        version: attr_local(&root, "Version").unwrap_or_default().to_owned(),
143        status: attr_local(&root, "Status").unwrap_or_default().to_owned(),
144        jdf_nodes: 1usize.saturating_add(count_named(&root, "JDF")),
145        resources: count_named(&root, "Resource")
146            .saturating_add(count_named(&root, "Component"))
147            .saturating_add(count_named(&root, "Media"))
148            .saturating_add(count_named(&root, "RunList"))
149            .saturating_add(count_named(&root, "Device"))
150            .saturating_add(count_named(&root, "FileSpec")),
151        resource_pools: count_named(&root, "ResourcePool"),
152        resource_links: count_named(&root, "ResourceLink"),
153        products: count_named(&root, "Product"),
154        processes: count_named(&root, "Process")
155            .saturating_add(count_named(&root, "ProcessGroup"))
156            .saturating_add(count_named(&root, "JDF")),
157        devices: count_named(&root, "Device"),
158        media: count_named(&root, "Media"),
159        layouts: count_named(&root, "Layout"),
160        run_lists: count_named(&root, "RunList"),
161        audits: count_named(&root, "Audit"),
162        files: count_named(&root, "FileSpec"),
163        urls: count_named(&root, "URLLink")
164            .saturating_add(count_named(&root, "URL"))
165            .saturating_add(count_named(&root, "FileSpec")),
166        ..Summary::default()
167    };
168    push_row(
169        &mut summary.rows,
170        "Document",
171        &summary.root,
172        &format!(
173            "version={} status={}",
174            display_or_dash(&summary.version),
175            display_or_dash(&summary.status)
176        ),
177    )?;
178    push_row(
179        &mut summary.rows,
180        "Nodes",
181        &summary.jdf_nodes.to_string(),
182        &format!(
183            "products={} processes={} audits={}",
184            summary.products, summary.processes, summary.audits
185        ),
186    )?;
187    push_row(
188        &mut summary.rows,
189        "Resources",
190        &summary.resources.to_string(),
191        &format!(
192            "pools={} links={} devices={} media={}",
193            summary.resource_pools, summary.resource_links, summary.devices, summary.media
194        ),
195    )?;
196    push_row(
197        &mut summary.rows,
198        "Print data",
199        &summary.layouts.to_string(),
200        &format!("runLists={} files={}", summary.run_lists, summary.files),
201    )?;
202    push_row(
203        &mut summary.rows,
204        "References",
205        &summary.urls.to_string(),
206        "URL/file links counted; targets not opened",
207    )?;
208    let blocks = vec![
209        HtmlBlock::Heading {
210            level: 1,
211            text: if xjdf {
212                "XJDF job ticket".into()
213            } else {
214                "JDF job ticket".into()
215            },
216        },
217        HtmlBlock::Paragraph {
218            text: "CIP4 Job Definition Format describes print and production workflows. This preview reports inert structure and never sends commands to devices.".into(),
219        },
220        HtmlBlock::Table(TableData {
221            headers: vec!["Kind".into(), "Value".into(), "Detail".into()],
222            rows: summary.rows,
223            alignments: vec![TableAlign::Left; 3],
224            raw_source: String::new(),
225        }),
226    ];
227    let warnings = vec![
228        "JDF/JMF nodes, resources and process metadata are shown; job IDs, customer values, credentials, URLs and file payloads are omitted or redacted".into(),
229        "JDF jobs, JMF messages, device commands, workflow execution, network requests and external files never run".into(),
230    ];
231    let mut page_sink = JdfPageSink {
232        inner: sink,
233        warnings: &warnings,
234        source_format: if xjdf { "xjdf" } else { "jdf" },
235    };
236    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
237    Ok(warnings)
238}
239
240fn attr_local<'a>(element: &'a XmlElement, name: &str) -> Option<&'a str> {
241    element.attributes.iter().find_map(|(key, value)| {
242        key.rsplit(':')
243            .next()
244            .filter(|local| local.eq_ignore_ascii_case(name))
245            .map(|_| value.as_str())
246    })
247}
248
249fn count_named(element: &XmlElement, name: &str) -> usize {
250    element
251        .children
252        .iter()
253        .map(|child| usize::from(child.name.eq_ignore_ascii_case(name)) + count_named(child, name))
254        .sum()
255}
256
257fn display_or_dash(value: &str) -> String {
258    if value.is_empty() {
259        "-".into()
260    } else {
261        truncate(value)
262    }
263}
264
265fn truncate(value: &str) -> String {
266    if value.len() <= MAX_JDF_DISPLAY_BYTES {
267        value.to_owned()
268    } else {
269        let mut end = MAX_JDF_DISPLAY_BYTES;
270        while !value.is_char_boundary(end) {
271            end -= 1;
272        }
273        format!("{}…", &value[..end])
274    }
275}
276
277fn push_row(rows: &mut Vec<Vec<String>>, kind: &str, value: &str, detail: &str) -> Result<()> {
278    if rows.len() >= MAX_JDF_ROWS {
279        return Err(Error::LimitExceeded(format!(
280            "JDF rows exceed {MAX_JDF_ROWS}"
281        )));
282    }
283    rows.push(vec![truncate(kind), truncate(value), truncate(detail)]);
284    Ok(())
285}