1use 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}