pdf-engine 1.0.0

Unified PDF rendering engine — page rendering, text extraction, thumbnails.
Documentation
//! A typographic ligature is one glyph for several letters, and `extract_text`
//! hands back the letters (#437, class R-G of #389).
//!
//! `office` set in a serif face is drawn with one `ffi` glyph, and a font that
//! carries a `ToUnicode` map for it usually names U+FB03 -- the code point for
//! the ligature -- rather than the three letters it stands for. Keeping that
//! code point is faithful to the file and useless to everyone downstream: a
//! search for `office`, a RAG chunk, a `grep`, an index all compare characters,
//! and U+FB03 is not `f` followed by `f` followed by `i`. mutool folds;
//! pdftotext does not, and its output is the reason a person who copies a word
//! out of a PDF cannot paste it back into a search box.
//!
//! So the extracted string folds, and the option to keep the code points rides
//! on the extraction device ([`crate::text::TextExtractionDevice::keep_ligatures`],
//! reachable from [`crate::PdfDocument::set_keep_ligatures`]). Structured
//! output is not folded at all: a [`crate::TextBlock`] span carries the
//! characters of the glyphs it was assembled from, because a caller who asked
//! for glyph-level geometry asked for the glyphs.
//!
//! WHAT IS FOLDED, AND WHAT IS DELIBERATELY NOT:
//!
//! - The ligatures of the Alphabetic Presentation Forms block, and only those:
//!   U+FB00-FB06 (Latin) and U+FB13-FB17 (Armenian). U+FB07-FB12 are
//!   unassigned, so the two ranges together are every ligature the block has.
//! - U+FB05 `ſt` folds to `st`, not to NFKC's `ſt`. The long s is a second
//!   presentation form of the same letter and has the same problem the
//!   ligature has: nobody types it. This is the one place the table departs
//!   from NFKC, and it departs in the direction the rest of the table goes.
//! - No other compatibility folding. NFKC also rewrites superscripts,
//!   fractions, full-width forms and the Arabic presentation forms, and each of
//!   those changes what a document says rather than how it was set: `2³` is not
//!   `23`, and `½` is not `1/2` in a table of measurements. A ligature is the
//!   only compatibility mapping where the glyph carries no meaning the letters
//!   do not.
//! - The Hebrew ligature U+FB4F `ﭏ` is outside both ranges and stays: it sits
//!   among the Hebrew presentation forms, where the neighbouring code points
//!   are pointed letters that must not be folded.

// Copyright (c) 2026 Innovation Trigger B.V.
//
// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
// the PDFluent Commercial Licence. See the LICENSE file in this repository --
// that file travels with the copy you received, which a URL does not.

use std::borrow::Cow;

/// The letters a ligature stands for, or `None` for every other character.
///
/// Latin first (U+FB00-FB06), then Armenian (U+FB13-FB17). The Armenian
/// expansions are the NFKC ones; the Latin ones are too, apart from U+FB05,
/// which the module docs explain.
pub(crate) const fn letters_behind(ch: char) -> Option<&'static str> {
    match ch {
        '\u{FB00}' => Some("ff"),
        '\u{FB01}' => Some("fi"),
        '\u{FB02}' => Some("fl"),
        '\u{FB03}' => Some("ffi"),
        '\u{FB04}' => Some("ffl"),
        '\u{FB05}' => Some("st"),
        '\u{FB06}' => Some("st"),
        '\u{FB13}' => Some("\u{574}\u{576}"),
        '\u{FB14}' => Some("\u{574}\u{565}"),
        '\u{FB15}' => Some("\u{574}\u{56B}"),
        '\u{FB16}' => Some("\u{57E}\u{576}"),
        '\u{FB17}' => Some("\u{574}\u{56D}"),
        _ => None,
    }
}

/// Replace every typographic ligature in `text` with the letters it stands for.
///
/// Borrows when there is nothing to fold, which is every page of almost every
/// document: the scan is one pass and the common answer costs no allocation.
pub(crate) fn fold(text: &str) -> Cow<'_, str> {
    if !text.chars().any(|ch| letters_behind(ch).is_some()) {
        return Cow::Borrowed(text);
    }

    // A ligature is never longer folded than three characters, and the input is
    // mostly not ligatures, so its own length is the right first guess.
    let mut folded = String::with_capacity(text.len());
    for ch in text.chars() {
        match letters_behind(ch) {
            Some(letters) => folded.push_str(letters),
            None => folded.push(ch),
        }
    }
    Cow::Owned(folded)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_latin_ligature_folds() {
        assert_eq!(
            fold("\u{FB00}\u{FB01}\u{FB02}\u{FB03}\u{FB04}\u{FB05}\u{FB06}"),
            "fffiflffifflstst"
        );
    }

    #[test]
    fn every_armenian_ligature_folds() {
        assert_eq!(
            fold("\u{FB13}\u{FB14}\u{FB15}\u{FB16}\u{FB17}"),
            "\u{574}\u{576}\u{574}\u{565}\u{574}\u{56B}\u{57E}\u{576}\u{574}\u{56D}"
        );
    }

    #[test]
    fn a_ligature_folds_where_it_sits() {
        assert_eq!(fold("o\u{FB03}ce hours"), "office hours");
    }

    /// The fast path has to be the same answer as the slow one, and it has to
    /// borrow: a page without a ligature must not be copied.
    #[test]
    fn text_without_a_ligature_is_not_copied() {
        assert!(matches!(fold("office hours"), Cow::Borrowed(_)));
    }

    /// The neighbours of the two ranges. U+FB07-FB12 are unassigned and U+FB4F
    /// is the Hebrew ligature the docs name; none of them may move.
    #[test]
    fn nothing_outside_the_two_ranges_folds() {
        for ch in ['\u{FB07}', '\u{FB12}', '\u{FB4F}', '\u{FEFF}', '\u{17F}'] {
            assert_eq!(letters_behind(ch), None, "{ch:?} should not fold");
        }
    }

    /// NFKC would fold these too, and the decision on #437 is that it must not:
    /// each one changes what the document says.
    #[test]
    fn no_other_compatibility_folding() {
        let untouched = "\u{B3}\u{BD}\u{FF21}\u{FDF2}"; // ³ ½ A ﷲ
        assert_eq!(fold(untouched), untouched);
    }
}