Skip to main content

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}