mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 13:08:48 -06:00
New modules: editor, interp, blocks, lines, rewrite, fallback. The regions and session modules stay #[path]-declared.
282 lines
14 KiB
Rust
282 lines
14 KiB
Rust
//! 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));
|
||
}
|
||
}
|