caj2pdf_core/operations/convert.rs
1// SPDX-License-Identifier: MIT
2
3//! [`convert`]: detection, dispatch to the format engines and the HN/C8
4//! resource wiring.
5
6use super::observe::{Observer, Progress, refused};
7use super::{Detection, InputFormat, detect_source, pdf_range};
8use crate::caj::convert_caj;
9use crate::hnc8::{
10 ApplicationInfoStatus, C8FontSource, C8FontSources, C8PageFonts, ComposeOptions, ComposePage,
11 ComposeVisitor, NativeDecorationGlyph, NativeFontGlyphAlias, NativeSymbolGlyph, OutlineReport,
12 SymbolFontIdentity, convert_document_pdf, uses_native_text,
13};
14use crate::jbig2::text::TextHeaderPolicy;
15use crate::kdh::convert_kdh;
16use crate::pdf::copy_pdf_range;
17use crate::qm::QmTable;
18use crate::{Error, ErrorKind, Limits, RangedSource, Result};
19use std::io::Write;
20
21/// The reason of the error [`convert`] returns when [`Fonts`] are given for a
22/// document other than HN or C8.
23pub const FONTS_REQUIRE_HNC8: &str =
24 "explicit native font resources require converting a C8 or HN-B document";
25
26/// Font resources for native HN/C8 text.
27///
28/// Up to eleven ranged resources and their collection faces; `roles` index
29/// into `sources`, and several roles may share one resource. Absent optional
30/// roles and unmapped characters follow the [`C8PageFonts`] fallback rule.
31#[derive(Default)]
32pub struct Fonts<'a> {
33 pub sources: Vec<C8FontSource<Box<dyn RangedSource + 'a>>>,
34 /// Which source draws each role; sources without roles are refused.
35 pub roles: Option<C8PageFonts>,
36 /// Used program preservation; defaults to compact subsets.
37 pub embedding: crate::pdf::FontEmbedding,
38 /// Ordinary glyph aliases, strictly sorted by source index and character.
39 pub glyph_aliases: Vec<NativeFontGlyphAlias>,
40 /// Explicit source glyphs for HN-B mode-0 symbol codes, drawn from the
41 /// `symbols` role without fallback. Semantic text is unchanged. Native
42 /// documents of another profile refuse a non-empty map; like the fonts,
43 /// it is unread for documents routed to image composition.
44 pub symbol_glyphs: Vec<NativeSymbolGlyph>,
45 /// The font `symbol_glyphs` was measured on; a different `symbols` font
46 /// is refused. Requires a nonempty map.
47 pub symbol_font: Option<SymbolFontIdentity>,
48 /// Explicit nonsemantic decoration aliases for native mode 2.
49 pub decoration_glyphs: Vec<NativeDecorationGlyph>,
50 /// Expected decoration font identity; requires a nonempty decoration map.
51 pub decoration_font: Option<SymbolFontIdentity>,
52}
53
54/// What [`convert`] produces from a document.
55pub struct ConversionOptions<'a> {
56 /// The input family; `None` detects it from the leading signature. An
57 /// explicit PDF must start with its `%PDF-` header.
58 pub format: Option<InputFormat>,
59 /// Write the CAJ, PDF or HN-A outline. C8/HN-B outlines are unverified and
60 /// never written; [`ConversionReport::outline`] says when one was omitted.
61 pub include_bookmarks: bool,
62 /// Replace damaged CAJ pages with blank pages and report every omission.
63 pub allow_damaged: bool,
64 /// Validation of HN/C8 type-3 text-region headers. The default admits the
65 /// measured HN/C8 unused-refinement-template anomaly; general JBIG2 APIs
66 /// and every other malformed flag stay strict.
67 pub text_header_policy: TextHeaderPolicy,
68 /// Fonts for native HN/C8 text. The core decides once per document
69 /// whether its text is native ([`crate::hnc8::uses_native_text`]); image
70 /// documents leave the fonts unread, so their PDF is byte-identical to a
71 /// conversion without fonts. Any other format refuses fonts with
72 /// [`FONTS_REQUIRE_HNC8`].
73 pub fonts: Option<Fonts<'a>>,
74}
75
76impl Default for ConversionOptions<'_> {
77 fn default() -> Self {
78 Self {
79 format: None,
80 include_bookmarks: true,
81 allow_damaged: false,
82 text_header_policy: TextHeaderPolicy::HnC8UnusedRefinementTemplate,
83 fonts: None,
84 }
85 }
86}
87
88/// A page whose content was replaced by an explicit blank page.
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub struct OmittedPage {
91 pub page_index: u32,
92 /// Absolute input offset of the damaged object or missing dependency owner.
93 pub offset: u64,
94}
95
96/// Images drawn by an HN/C8 conversion, by codec.
97#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
98pub struct ImageCounts {
99 pub type0: u64,
100 pub jpeg: u64,
101 pub type3: u64,
102}
103
104/// Counters and warnings from a completed conversion.
105#[derive(Clone, Debug, Default, Eq, PartialEq)]
106pub struct ConversionReport {
107 /// Input bytes read, detection included.
108 pub input_bytes_read: u64,
109 pub output_bytes_written: u64,
110 pub pages_converted: u32,
111 /// Private-use glyphs drawn with a visual substitute from the caller's
112 /// font. Original private-use codes are retained in PDF ActualText; this
113 /// count does not imply a standard Unicode identity for their shapes.
114 pub substituted_glyphs: u64,
115 pub bookmarks_written: u32,
116 /// Blank substitutions in source page order; indices are zero-based.
117 pub omitted_pages: Vec<OmittedPage>,
118 /// HN-A outline entries skipped or clamped, or C8/HN-B bookmarks omitted
119 /// as unverified; empty for other formats.
120 pub outline: OutlineReport,
121 /// Whether a C8 application-info package was read into the PDF `/Info`
122 /// or ignored as defective; `Absent` for other formats.
123 pub application_info: ApplicationInfoStatus,
124 /// Images drawn by an HN/C8 conversion; zero for other formats.
125 pub images: ImageCounts,
126}
127
128/// Convert one document to PDF bytes written to `sink`.
129///
130/// The family is detected from the leading signature unless
131/// [`ConversionOptions::format`] names it, and reported to
132/// [`Progress::format`]. PDF is copied from its `%PDF-` header, CAJ and KDH
133/// are rebuilt, and HN/C8 pages are composed, with native text when the
134/// document needs it and [`ConversionOptions::fonts`] are given. Every
135/// source page becomes an output page or the conversion fails; only
136/// [`ConversionOptions::allow_damaged`] substitutes blank CAJ pages, and
137/// [`ConversionReport::omitted_pages`] lists them.
138///
139/// An empty or unrecognized input, and NH or TEB, are refused with an
140/// [`ErrorKind::UnsupportedFormat`] error that has no offset, context or
141/// reason. On any error the bytes already written are not a valid PDF;
142/// callers that need atomic output stage it and commit only on success.
143///
144/// ```no_run
145/// use caj2pdf_core::{ConversionOptions, Limits, NeverCancel, convert, native::SeekableSource};
146/// use std::{fs::File, io::BufWriter};
147///
148/// let mut input = SeekableSource::new(File::open("paper.caj")?)?;
149/// let mut output = BufWriter::new(File::create("paper.pdf")?);
150/// let report = convert(
151/// &mut input,
152/// &mut output,
153/// ConversionOptions::default(),
154/// &Limits::default(),
155/// &mut NeverCancel,
156/// )?;
157/// println!("{} pages, {} bookmarks", report.pages_converted, report.bookmarks_written);
158/// # Ok::<(), Box<dyn std::error::Error>>(())
159/// ```
160///
161/// Any [`RangedSource`] works, a byte slice included:
162///
163/// ```
164/// use caj2pdf_core::{ConversionOptions, ErrorKind, Limits, NeverCancel, convert};
165///
166/// let mut output = Vec::new();
167/// let error = convert(
168/// &mut &b"not a document"[..],
169/// &mut output,
170/// ConversionOptions::default(),
171/// &Limits::default(),
172/// &mut NeverCancel,
173/// )
174/// .unwrap_err();
175/// assert!(matches!(error.kind, ErrorKind::UnsupportedFormat));
176/// assert!(output.is_empty());
177/// ```
178pub fn convert<S: RangedSource, W: Write>(
179 source: &mut S,
180 sink: &mut W,
181 options: ConversionOptions<'_>,
182 limits: &Limits,
183 progress: &mut dyn Progress,
184) -> Result<ConversionReport> {
185 convert_inner(source, sink, options, None, limits, progress)
186}
187
188/// Convert the measured TTKN server-auth PDF profile using an explicit response.
189/// No authentication endpoint is contacted. The response is case-sensitive and
190/// is never included in errors. Other TTKN profiles remain unsupported.
191/// Atomic-output requirements are the same as for [`convert`].
192pub fn convert_with_ttkn_response<S: RangedSource, W: Write>(
193 source: &mut S,
194 sink: &mut W,
195 options: ConversionOptions<'_>,
196 response: &crate::pdf::TtknResponse,
197 limits: &Limits,
198 progress: &mut dyn Progress,
199) -> Result<ConversionReport> {
200 convert_inner(source, sink, options, Some(response), limits, progress)
201}
202
203fn convert_inner<S: RangedSource, W: Write>(
204 source: &mut S,
205 sink: &mut W,
206 options: ConversionOptions<'_>,
207 response: Option<&crate::pdf::TtknResponse>,
208 limits: &Limits,
209 progress: &mut dyn Progress,
210) -> Result<ConversionReport> {
211 let observer = Observer::new(progress);
212 let mut source = observer.track(source);
213 let Detection {
214 format,
215 header_offset,
216 bytes_read,
217 } = observer.resolve(&mut source, options.format, limits)?;
218 if options.fonts.is_some() && !matches!(format, InputFormat::Hn | InputFormat::C8) {
219 return Err(Error::invalid(FONTS_REQUIRE_HNC8));
220 }
221 if response.is_some() && format != InputFormat::Pdf {
222 return Err(Error::invalid("TTKN response requires a PDF input"));
223 }
224 let mut report = match format {
225 InputFormat::Pdf => {
226 let range = pdf_range(source.size(), header_offset);
227 match response {
228 Some(response) => {
229 crate::pdf::convert_ttkn(&mut source, sink, range, response, limits, &observer)?
230 }
231 None => copy_pdf_range(&mut source, sink, range, limits, &observer)?,
232 }
233 }
234 InputFormat::Caj => convert_caj(&mut source, sink, &options, limits, &observer)?,
235 InputFormat::Kdh => convert_kdh(&mut source, sink, limits, &observer)?,
236 InputFormat::Hn | InputFormat::C8 => {
237 convert_hnc8(&mut source, sink, options, limits, &observer)?
238 }
239 InputFormat::Nh | InputFormat::Teb | InputFormat::Caa => return Err(refused()),
240 };
241 report.input_bytes_read = report.input_bytes_read.saturating_add(bytes_read);
242 Ok(report)
243}
244
245/// Every source page must become an output page: a page without image
246/// content is refused, never dropped.
247struct CompletePages;
248
249impl ComposeVisitor for CompletePages {
250 fn page(&mut self, page: ComposePage<'_>) -> Result<()> {
251 if page.output_page.is_none() {
252 return Err(Error::from(ErrorKind::UnsupportedFormat)
253 .because("HN/C8 conversion cannot omit source pages without image content"));
254 }
255 Ok(())
256 }
257}
258
259/// Compose an HN/C8 document with the standard QM table, routing it to
260/// native composition when it has native text and fonts are given.
261fn convert_hnc8<S: RangedSource, W: Write>(
262 source: &mut S,
263 sink: &mut W,
264 options: ConversionOptions<'_>,
265 limits: &Limits,
266 observer: &Observer<'_>,
267) -> Result<ConversionReport> {
268 let compose = ComposeOptions {
269 text_header_policy: options.text_header_policy,
270 include_bookmarks: options.include_bookmarks,
271 };
272 let mut fonts = options
273 .fonts
274 .map(|fonts| {
275 let roles = fonts
276 .roles
277 .ok_or(Error::invalid("C8 font resources require explicit roles"))?;
278 for font in &fonts.sources {
279 limits.check_input_size(font.source.size())?;
280 }
281 Ok::<_, Error>((fonts, roles))
282 })
283 .transpose()?;
284 let report = convert_document_pdf(
285 source,
286 sink,
287 fonts.as_mut().map(|(fonts, roles)| C8FontSources {
288 sources: &mut fonts.sources,
289 roles: *roles,
290 embedding: fonts.embedding,
291 glyph_aliases: &fonts.glyph_aliases,
292 symbol_glyphs: &fonts.symbol_glyphs,
293 symbol_font: fonts.symbol_font.as_ref(),
294 decoration_glyphs: &fonts.decoration_glyphs,
295 decoration_font: fonts.decoration_font.as_ref(),
296 }),
297 Some(&QmTable::standard()),
298 &mut CompletePages,
299 compose,
300 limits,
301 observer,
302 )?;
303 Ok(ConversionReport {
304 outline: report.outline,
305 application_info: report.application_info,
306 images: ImageCounts {
307 type0: report.type0_images,
308 jpeg: report.jpeg_images,
309 type3: report.type3_images,
310 },
311 ..report.conversion
312 })
313}
314
315/// Whether [`convert`] would draw native text for this input and therefore
316/// needs [`Fonts`]: an HN or C8 document that
317/// [`crate::hnc8::uses_native_text`] routes to native composition. Other
318/// families, and empty or unrecognized inputs, need none; converting them
319/// reports their own errors. Only a failed read or cancellation is an error.
320/// [`Progress::format`] is not called.
321pub fn needs_fonts<S: RangedSource>(
322 source: &mut S,
323 limits: &Limits,
324 progress: &mut dyn Progress,
325) -> Result<bool> {
326 let observer = Observer::new(progress);
327 let mut source = observer.track(source);
328 match detect_source(&mut source, limits, &observer) {
329 Ok(Some(Detection {
330 format: InputFormat::Hn | InputFormat::C8,
331 ..
332 })) => uses_native_text(&mut source, limits, &observer),
333 _ => Ok(false),
334 }
335}
336
337/// Native rendering mode when the input needs fonts, otherwise `None`.
338/// Uses the same bounded routing as [`needs_fonts`]; does not read fonts.
339pub fn native_font_mode<S: RangedSource>(
340 source: &mut S,
341 limits: &Limits,
342 progress: &mut dyn Progress,
343) -> Result<Option<u32>> {
344 if !needs_fonts(source, limits, progress)? {
345 return Ok(None);
346 }
347 let observer = Observer::new(progress);
348 let mut source = observer.track(source);
349 Ok(
350 crate::hnc8::Hnc8Reader::open(&mut source, limits, &observer)?
351 .header()
352 .native_mode,
353 )
354}