Files
pdfcraft/crates/edit/src/text.rs
T
aiongg 157cbf3a9d refactor(edit): split text.rs into text/
New modules: editor, interp, blocks, lines, rewrite, fallback. The regions and session modules stay #[path]-declared.
2026-10-26 22:18:50 +00:00

282 lines
14 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Edit a PDF ▸ Edit text: the text lines already on a page, and replacing a line's text in place.
//!
//! A *line* is a run of compatible text-showing operators on the same baseline, read
//! from one content stream. Replacing it rewrites only that stream (as a new object, since content
//! streams may be shared): the first operator of the line shows the new text, the others are
//! dropped, and every positioning operator stays, so the lines after it keep their places
//! (`Td`, `T*` and friends move from the line matrix, which showing text doesn't change).
//!
//! The new text uses the line's own font when every character has a code and a glyph in it;
//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted.
//! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`).
use std::collections::HashMap;
use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, PdfString, Stream};
use pdfcraft_fonts::pdf::Metrics;
use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from};
use crate::EditError;
#[path = "text_regions.rs"]
mod regions;
#[path = "text_session.rs"]
mod session;
use regions::{group_blocks, is_centred, rewrite_styled_block};
pub use session::TextSession;
mod blocks;
mod editor;
mod fallback;
mod interp;
mod lines;
mod rewrite;
use blocks::paragraph_separator;
#[cfg(test)]
use blocks::wrap;
pub use blocks::{
BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked,
replace_block, text_blocks,
};
use editor::layout_draft;
pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts};
#[cfg(test)]
pub(crate) use fallback::SOURCE_SPACE_CALLS;
use fallback::{
MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes,
source_family, source_program, source_show, source_space, source_width, strip_subset_tag, type3_faces, type3_glyph, write_type3_glyphs,
};
#[cfg(test)]
use fallback::{add_type3_font, is_type3_fallback};
use interp::{Origin, Paint, union};
use lines::page_dict;
pub(crate) use lines::{FormBudget, content_streams, content_streams_within, form_call, rewritten_contents, splice};
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
pub use rewrite::{LineEdit, replace_line, rewrite_block};
use rewrite::{coincident_ops, rewrite_block_from};
#[cfg(test)]
pub(crate) use rewrite::{replace_line_with_limit, rewrite_block_with_limit};
pub(crate) use rewrite::{reveal_ops, selectable_blocks, translate_blocks};
/// One line of existing text.
#[derive(Clone, Debug, PartialEq)]
pub struct TextLine {
/// The line's text as shown.
pub text: String,
/// Its box in user space.
pub rect: [f64; 4],
/// The boxes of the words it draws, in user space: from each word's first glyph to the end of
/// its last, without the spaces and `TJ` adjustments around it. `rect` also spans gaps that
/// draw nothing, such as the adjustment an applied redaction leaves where text was removed.
pub ink: Vec<[f64; 4]>,
/// The font's resource name and `/BaseFont` (or Type 3 descriptor's `/FontName`), and its size in text space.
pub font: String,
pub base_font: String,
pub size: f64,
pub bold: bool,
pub italic: bool,
/// The current fill colour as RGB.
pub color: [f64; 3],
/// Whether new text in this font can only be shown by substituting another font (no
/// Unicode mapping for the line, so nothing could be reused).
pub decodable: bool,
/// Whether the font requires visual-order Indic text.
pub visual_order: bool,
/// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]).
face: Option<String>,
/// Its box along and across its baseline, `[along0, across0, along1, across1]` (see
/// [`TextLine::frame_rect`]). Upright text has `rect`; turned text has its own box, which is
/// narrower than `rect` turned into the same frame.
frame: [f64; 4],
/// The content stream piece the line starts in, and its operators (indices into the page's
/// streams parsed joined).
stream: usize,
ops: Vec<usize>,
/// Where the line starts: text matrix (text space), the state there, and its `BT`.
origin: Origin,
/// Explicit marked-content scope (when present); distinct scopes must not be guessed into one paragraph.
scope: Option<usize>,
/// The layers and graphics state parameters it is drawn in; see [`Paint`].
paint: Paint,
/// Right-to-left `/ActualText` already supplies typing order; glyph text still needs bidi
/// recovery. Left-to-right ActualText (e.g. a Latin ligature) joins its line as usual.
logical_order: bool,
/// Some of the line's text is `/ActualText`, which is already in logical order: visual-order
/// Indic recovery would scramble it.
actual_text: bool,
/// Whether any of the line's runs draws in a clipping mode (`4 Tr` to `7 Tr`). The join below
/// does not require one rendering mode, so a line that starts as ink can continue as a mask;
/// [`refuse_clipping_text`] reads this rather than the origin's mode alone.
clips: bool,
/// Why the line can't be edited: its font's writing mode (its CMap's `WMode`) couldn't be read,
/// so which way its glyphs advance is unknown. The line is listed as horizontal text.
unreadable_mode: Option<String>,
/// The runs the line joins, in content order: each one's text, the end of its words in
/// `ink`, and whether joining put a space before it. Empty when the line's text was put
/// into another order (see [`TextLine::runs`]).
runs: Vec<(String, usize, bool)>,
}
impl TextLine {
/// Refuses editing a line whose font's writing mode couldn't be read (§9.7.4.3).
fn ensure_editable(&self) -> Result<(), EditError> {
self.unreadable_mode.as_ref().map_or(Ok(()), |why| Err(EditError::Invalid(why.clone())))
}
/// Origin of the source baseline in PDF user space, independent of font ascent/descent.
pub fn baseline(&self) -> [f64; 2] {
[self.origin.x, self.origin.baseline]
}
/// Leading used when a single source run wraps into additional rows.
pub fn wrap_leading(&self) -> f64 {
if self.origin.state.leading > 0.0 { self.origin.state.leading * self.origin.k } else { self.size * 1.2 }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn block(text: &str, rect: [f64; 4]) -> TextBlock {
TextBlock {
text: text.into(),
rect,
base_font: String::new(),
size: 12.0,
bold: false,
italic: false,
color: [0.0; 3],
rotation: 0.0,
lines: Vec::new(),
rows: Vec::new(),
editor_text: text.into(),
spans: Vec::new(),
invisible: false,
}
}
#[test]
fn a_block_key_finds_its_paragraph_after_others_move_and_not_a_neighbour_in_its_place() {
let (a, b, c) = (block("Alpha", [0.0, 80.0, 50.0, 100.0]), block("Bravo", [0.0, 50.0, 50.0, 60.0]), block("Charlie", [0.0, 0.0, 50.0, 20.0]));
let key = BlockKey::of(&b);
assert_eq!(key.find(&[a.clone(), b.clone(), c.clone()], 1), Some(1));
// Alpha and Bravo became one paragraph: Bravo's number now belongs to Charlie.
let merged = block("Alpha Bravo", [0.0, 50.0, 50.0, 100.0]);
assert_eq!(key.find(&[merged, c.clone()], 1), None);
// A paragraph that split in two shifts the others down.
assert_eq!(key.find(&[a.clone(), block("x", [0.0; 4]), b.clone(), c], 1), Some(2));
// The same words somewhere else, or other words in the same place, are not the paragraph.
assert_eq!(key.find(&[block("Bravo", [0.0, 40.0, 50.0, 50.0])], 0), None);
assert_eq!(key.find(&[block("Bravo!", b.rect)], 0), None);
assert_eq!(key.find(&[], 0), None);
// Two paragraphs drawn on top of one another are told apart by the hint.
assert_eq!(key.find(&[b.clone(), b], 1), Some(1));
// A box that is not a number matches nothing, and cannot panic.
let nan = BlockKey::of(&block("Bravo", [f64::NAN; 4]));
assert_eq!(nan.find(&[block("Bravo", [f64::NAN; 4])], 0), None);
}
#[test]
fn the_block_that_overlaps_most_is_the_edited_paragraph() {
let blocks = [block("a", [0.0, 80.0, 50.0, 100.0]), block("b", [0.0, 40.0, 50.0, 70.0])];
assert_eq!(block_overlapping(&blocks, [0.0, 60.0, 50.0, 95.0]), Some(0));
assert_eq!(block_overlapping(&blocks, [0.0, 35.0, 50.0, 75.0]), Some(1));
assert_eq!(block_overlapping(&blocks, [200.0, 0.0, 250.0, 10.0]), None, "a box that overlaps nothing");
assert_eq!(block_overlapping(&[], [0.0, 0.0, 1.0, 1.0]), None);
assert_eq!(block_overlapping(&blocks, [f64::NAN; 4]), None);
}
#[test]
fn a_subset_tag_is_the_only_difference_between_subsets_of_one_font() {
assert!(same_base_font("ABCDEF+Arial", "GHIJKL+Arial"));
assert!(same_base_font("Arial", "ABCDEF+Arial"));
assert!(same_base_font("Arial", "Arial"));
assert!(!same_base_font("ABCDEF+Arial", "GHIJKL+Helvetica"));
// Five letters are part of the name, not a subset tag.
assert!(!same_base_font("ABCDE+Helvetica-Bold", "Helvetica-Bold"));
// A tag with no name after it, or no name at all, names no font.
assert!(!same_base_font("ABCDEF+", "GHIJKL+"));
assert!(!same_base_font("", ""));
assert!(!same_base_font("Arial", ""));
}
#[test]
fn paragraph_wrapping_keeps_ideographic_spaces() {
let width = |s: &str| s.chars().count() as f64;
assert_eq!(wrap("A  B C", 4.0, width), ["A  B", "C"]);
assert_eq!(wrap("A\u{a0}B C", 3.0, width), ["A B", "C"]);
assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]);
}
/// #4199: a run of spaces stays a run, before, between and after words; the spaces where a
/// row wraps go, and each other white-space character is one space.
#[test]
fn paragraph_wrapping_keeps_every_space_typed() {
let width = |s: &str| s.chars().count() as f64;
assert_eq!(wrap("a b c", 20.0, width), ["a b c"]);
assert_eq!(wrap(" a b ", 20.0, width), [" a b "]);
assert_eq!(wrap("a\t\tb", 20.0, width), ["a b"]);
assert_eq!(wrap("aa bb cc", 6.0, width), ["aa bb", "cc"]);
// An indent alone on a row doesn't wrap before its first word.
assert_eq!(wrap(" abc", 3.0, width), [" abc"]);
// The rewrite turns `\r\n` and `\r` into `\n` before wrapping.
assert_eq!(wrap(" \tA B\nC ", 3.0, width), [" A", "B", "C "]);
assert_eq!(wrap(" ", 3.0, width), [" "]);
assert_eq!(wrap("", 3.0, width), [""]);
}
#[test]
fn japanese_text_wraps_between_characters_but_not_before_closing_marks() {
let width = |s: &str| s.chars().count() as f64;
assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]);
// The comma can't begin a row, so the character before it moves down with it.
assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]);
// Pieces of one word join without a space; separate words keep theirs.
assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]);
assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]);
}
#[test]
fn fallback_face_moves_on_when_the_best_face_lacks_a_character() {
static JPAN: &[&str] = &["Jpan"];
let mincho = CraftFont { family: "Mincho", style: "Regular", scripts: JPAN, bytes: b"" };
let gothic = CraftFont { family: "Gothic", style: "Regular", scripts: JPAN, bytes: b"" };
let faces = [&mincho, &gothic];
// Only the Gothic face has the Cyrillic letter.
let has = |face: &CraftFont, ch: char| ch.is_ascii() || face.family == "Gothic";
assert_eq!(fallback_face(&faces, &['a', 'ф'], has).map(|f| f.family), Some("Gothic"));
// Characters the best face has keep it.
assert_eq!(fallback_face(&faces, &['a', 'b'], has).map(|f| f.family), Some("Mincho"));
// No face has them all: the best face stays, and names the missing glyph.
assert_eq!(fallback_face(&faces, &['a', 'ф'], |_, ch| ch.is_ascii()).map(|f| f.family), Some("Mincho"));
assert!(fallback_face(&[], &['a'], has).is_none());
}
#[test]
fn hebrew_and_japanese_fallback_shards_are_recognized_as_ours() {
static HEBR: &[&str] = &["Hebr"];
let face = CraftFont { family: "FakeHebrew", style: "Regular", scripts: HEBR, bytes: b"" };
let glyph = |code, ch| Type3Glyph { code, ch, path: b"0 0 0 0 0 0 d1\nf\n".to_vec(), width: 0.5 };
let mut doc = Document::new_empty();
let mut fonts = Dict::new();
// A replacement split across two Hebrew fonts, then one Japanese font.
let he = add_type3_font(&mut doc, &mut fonts, &face, true, vec![glyph(1, 'א')]);
let he1 = add_type3_font(&mut doc, &mut fonts, &face, true, vec![glyph(1, 'ב')]);
let jp = add_type3_font(&mut doc, &mut fonts, &face, false, vec![glyph(1, 'あ')]);
assert_eq!([he.as_str(), he1.as_str(), jp.as_str()], ["PCHe", "PCHe1", "PCJp"]);
for name in [&he, &he1, &jp] {
let font = fonts.get(name.as_bytes()).and_then(Object::as_dict).cloned().unwrap_or_default();
assert!(is_type3_fallback(&font), "{name} isn't recognized as a generated fallback font");
}
// Someone else's Type 3 font is not ours.
let mut other = Dict::new();
other.set(b"Subtype".to_vec(), Object::name("Type3"));
other.set(b"Name".to_vec(), Object::name("PCHebrewX"));
assert!(!is_type3_fallback(&other));
}
}