Refactor edit for strict Clippy checks

This commit is contained in:
Dylan M. Taylor
2026-10-27 23:12:51 +00:00
parent 84c873d2e1
commit de73c35e17
18 changed files with 1634 additions and 1081 deletions
+4 -1
View File
@@ -2,7 +2,10 @@ use super::layout::{
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped,
};
use super::types::{AddedText, Align, Family};
use super::*;
use super::{
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
literal, matrix_number, n, shaped_glyph, win_ansi,
};
fn font_name(base: &str) -> String {
format!("PCF{}", base.replace('-', ""))
+4 -1
View File
@@ -1,5 +1,8 @@
use super::types::{AddedImage, AddedText};
use super::*;
use super::{
Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew,
shape_thai,
};
pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default()
+1 -1
View File
@@ -1,7 +1,7 @@
use super::draw::{draw, params};
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
use super::*;
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged};
fn parse(doc: &Document, d: &Dict) -> Option<Content> {
let r = nums(doc, d.get(b"Rect"));
+1 -1
View File
@@ -1,5 +1,5 @@
use super::layout::norm;
use super::*;
use super::{Content, MarkKind, ObjRef, Std14};
pub(super) const TAG: &str = "Added";
+1 -1
View File
@@ -5,7 +5,7 @@ use super::place::{
END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place,
place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role,
};
use super::*;
use super::{Document, EditError, Object, added, helvetica_width};
/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on
/// those pages are removed first (Acrobat's Replace Existing).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Document, EditError, Object, Rgb, added};
/// The kinds of page marks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+1 -1
View File
@@ -1,5 +1,5 @@
use super::marks::{MarkKind, matrix_number, n};
use super::*;
use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi};
pub(crate) fn page_list(doc: &Document) -> Vec<pdfcraft_model::Page> {
pdfcraft_model::pages(doc)
+118 -75
View File
@@ -6664,12 +6664,124 @@ fn actual_text_pairs_past_the_marked_content_cap() {
}
}
/// #2992: the colour a page is painted with before anything else is its background, for exports
/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes
/// behind; a page that paints anything else first, a fill that doesn't cover the page, a
/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none.
fn page_background_from_content(content: &str, resources: &str) -> Option<[f64; 3]> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (index, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes());
}
let xref_offset = bytes.len();
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes());
page_background(&Document::open(Arc::new(bytes)).unwrap(), 0).unwrap()
}
fn assert_page_background_fill_cases(gs: &str) {
assert_eq!(page_background_from_content("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill");
assert_eq!(page_background_from_content("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box");
assert_eq!(page_background_from_content("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc");
assert_eq!(page_background_from_content("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm");
assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page");
assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle");
assert_eq!(
page_background_from_content("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""),
Some([1.0, 0.0, 0.0]),
"one of the rectangles covers it"
);
// #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the
// other way under nonzero.
assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole");
assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle");
assert_eq!(
page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""),
Some([1.0, 0.0, 0.0]),
"nonzero, same winding: no hole"
);
assert_eq!(
page_background_from_content("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""),
None,
"reversed by a mirroring cm"
);
assert_eq!(page_background_from_content("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent");
assert_eq!(page_background_from_content("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended");
assert_eq!(page_background_from_content("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner");
assert_eq!(page_background_from_content("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip");
assert_eq!(page_background_from_content("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern");
assert_eq!(page_background_from_content("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first");
assert_eq!(page_background_from_content("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle");
assert_eq!(
page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""),
None,
"a page fill with a hole cut in it"
);
}
fn assert_page_background_transform_and_clip_cases() {
// #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop
// box's corners are inside it; its bounding box covering the crop box isn't enough. This one
// reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but
// 354 along the diagonals, short of the corners. One reaching 707 covers them.
let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm";
assert_eq!(
page_background_from_content(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""),
None,
"a turned fill short of the corners"
);
assert_eq!(
page_background_from_content(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""),
Some([1.0, 0.0, 0.0]),
"a turned fill over them"
);
// The same for a clip.
assert_eq!(
page_background_from_content(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
None,
"a turned clip"
);
assert_eq!(
page_background_from_content(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
Some([1.0, 0.0, 0.0]),
"a turned clip over the corners"
);
// Clips add up: two that each cover the page leave it covered, one that doesn't among them
// doesn't.
assert_eq!(
page_background_from_content("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""),
Some([1.0, 0.0, 0.0]),
"two covering clips"
);
assert_eq!(
page_background_from_content("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""),
None,
"the second clip doesn't cover"
);
// Past 64 clipping rectangles the clip counts as unknown.
let clips = "0 0 600 800 re W n ".repeat(65);
assert_eq!(page_background_from_content(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow");
// A rectangle drawn the other way round (negative width) covers the page all the same.
assert_eq!(page_background_from_content("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise");
// The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the
// edge of that included.
assert_eq!(page_background_from_content("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance");
assert_eq!(page_background_from_content("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge");
assert_eq!(page_background_from_content("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance");
}
/// #2992: page background detection follows the first full-page fill under text and ignores
/// partial, translucent, clipped, or unsupported fills.
#[test]
fn a_page_painted_first_with_a_full_page_fill_has_that_background() {
let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>";
let mut doc = fixture();
assert_eq!(page_background(&doc, 0).unwrap(), None, "text first");
add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap();
@@ -6680,77 +6792,8 @@ fn a_page_painted_first_with_a_full_page_fill_has_that_background() {
assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content");
assert!(page_background(&doc, 9).is_err());
let page = |content: &str, resources: &str| {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes());
page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap()
};
let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>";
assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill");
assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box");
assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc");
assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm");
assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page");
assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle");
assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it");
// #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the
// other way under nonzero.
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole");
assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm");
assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent");
assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended");
assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner");
assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip");
assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern");
assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first");
assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it");
// #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop
// box's corners are inside it; its bounding box covering the crop box isn't enough. This one
// reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but
// 354 along the diagonals, short of the corners. One reaching 707 covers them.
let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm";
assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners");
assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them");
// The same for a clip.
assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip");
assert_eq!(
page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
Some([1.0, 0.0, 0.0]),
"a turned clip over the corners"
);
// Clips add up: two that each cover the page leave it covered, one that doesn't among them
// doesn't.
assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips");
assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover");
// Past 64 clipping rectangles the clip counts as unknown.
let clips = "0 0 600 800 re W n ".repeat(65);
assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow");
// A rectangle drawn the other way round (negative width) covers the page all the same.
assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise");
// The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the
// edge of that included.
assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance");
assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge");
assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance");
assert_page_background_fill_cases(gs);
assert_page_background_transform_and_clip_cases();
}
#[test]
+1 -1
View File
@@ -1,7 +1,7 @@
use super::interp::{Carry, interpret};
use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines};
use super::rewrite::{LineEdit, coincident_ops, rewrite_block};
use super::*;
use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions};
/// An editable text region: adjacent runs on visual rows, followed by aligned rows.
/// A region may contain several source fonts, sizes, colours and weights.
+1 -1
View File
@@ -2,7 +2,7 @@ use super::blocks::{BlockStyle, TextBlock};
use super::fallback::MAX_TYPE3_GLYPHS;
use super::lines::{page_dict, text_lines};
use super::rewrite::rewrite_block_from;
use super::*;
use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession};
/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and
/// are never installed, copied into assets, or used as a font for another document.
+3 -1
View File
@@ -1,6 +1,8 @@
use super::interp::Ts;
use super::lines::page_dict;
use super::*;
use super::{
CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse,
};
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
pub(super) type Encoder = Box<dyn Fn(&str) -> Option<Vec<(String, Vec<u8>)>>>;
+283 -249
View File
@@ -1,6 +1,6 @@
use super::fallback::is_type3_fallback;
use super::rewrite::vertical_advance;
use super::*;
use super::{Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, Rc};
/// Text and graphics state captured for rewriting.
#[derive(Clone, Debug, PartialEq)]
@@ -472,6 +472,280 @@ type CachedFont = (Rc<Metrics>, Option<String>, Result<Option<f64>, String>);
/// A `Do` met while interpreting: its operator index, XObject name and the state there.
type Invocation = (usize, Vec<u8>, Ts);
#[derive(Default)]
struct MarkedContent {
regions: Vec<Option<usize>>,
layers: Vec<Option<Vec<u8>>>,
actual_text: Vec<Option<(usize, usize, String)>>,
}
impl MarkedContent {
fn begin(&mut self, doc: &Document, op: &Op, index: usize, logical_text: Option<&Dict>, shown_count: usize) {
// Marked text paragraphs/headings are authoritative boundaries. Ignore artifacts and
// inner spans, which inherit their enclosing region.
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
self.regions.push(if region { Some(index) } else { self.regions.last().copied().flatten() });
// A layer named in the resources is told apart by its name, an inline one by where it opens.
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
self.layers
.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec)));
// The property list is inline or named in the resources' /Properties (§14.6.2).
let list = logical_text.and_then(|properties| match op.operands.get(1) {
Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)),
other => other.and_then(Object::as_dict).cloned(),
});
let logical = list.as_ref().and_then(|dict| dict.get(b"ActualText")).and_then(|value| match value {
Object::String(value) if value.bytes.len() <= 4_000_000 => Some(value.to_text()),
_ => None,
});
self.actual_text.push(logical.map(|text| (shown_count, index, text)));
}
fn end(&mut self, index: usize, shown: &mut Vec<Shown>) {
self.regions.pop();
self.layers.pop();
if let Some(Some((start, open, logical))) = self.actual_text.pop() {
let tail = shown.split_off(start.min(shown.len()));
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for item in items {
first.glyph_count = first.glyph_count.saturating_add(item.glyph_count);
first.extra_ops.push(item.op);
first.extra_ops.extend(item.extra_ops);
first.rect = [
first.rect[0].min(item.rect[0]),
first.rect[1].min(item.rect[1]),
first.rect[2].max(item.rect[2]),
first.rect[3].max(item.rect[3]),
];
first.frame = union(first.frame, item.frame);
first.ink.extend(item.ink);
}
first.extra_ops.extend([open, index]);
// Only right-to-left ActualText is already in typing order and must stay out of
// bidi recovery. Word and Chrome also wrap Latin ligatures mid-word.
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
first.actual_text = true;
first.text = logical;
first.start_x = first.rect[0];
first.end_x = first.rect[2];
first.end_y = first.baseline;
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
shown.push(first);
}
}
}
}
struct TextRunState<'a> {
ts: &'a mut Ts,
tm: &'a mut Matrix,
tlm: &'a mut Matrix,
at_bt: &'a (usize, Ts),
bt: usize,
marked: &'a MarkedContent,
carry: &'a mut Carry,
out: &'a mut Vec<Shown>,
}
fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> {
let ts = &mut *state.ts;
let tm = &mut *state.tm;
let tlm = &mut *state.tlm;
let at_bt = state.at_bt;
let bt = state.bt;
let marked = state.marked;
let carry = &mut *state.carry;
let out = &mut *state.out;
if matches!(op.op.as_slice(), b"'" | b"\"") {
if op.is("\"") {
ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing);
ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing);
}
*tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(tlm);
*tm = *tlm;
}
let Some((name, m)) = ts.font.clone() else { return Ok(()) };
// Pieces: strings, and TJ number adjustments.
let pieces: Vec<Object> = match op.op.as_slice() {
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still
// takes the one immediately before it, as the renderer and every other
// reader here do (#3569) -- not the first, which a reader counting operands
// from the front of a surplus list would otherwise take.
b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
// Tc is already included in the pen advance; TJ may encode tracking instead.
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
let trm0 = tm.then(&ts.ctm);
let start = trm0.apply(0.0, ts.rise);
let mut text = String::new();
let mut decodable = true;
let mut x_text = 0.0;
// Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred
// on it horizontally; `half` is half the widest glyph.
let vertical = ts.vertical;
let (mut y_text, mut half) = (0.0, 0.0_f64);
// Where each word's glyphs start and end along the run (text space), and whether
// the next glyph continues the last word.
let mut words: Vec<(f64, f64)> = Vec::new();
let mut in_word = false;
let mut glyph_count = 0_usize;
for p in &pieces {
match p {
Object::String(s) => {
for (code, len) in m.codes(&s.bytes) {
glyph_count = glyph_count.saturating_add(1);
let shown = m.text_of(code);
let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
match shown {
Some(t) => {
carry.text_left = carry
.text_left
.checked_sub(t.len())
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
text.push_str(&t);
}
None => decodable = false,
}
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
if let Some(w1) = vertical {
half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0);
if !blank {
let ink_end = y_text + w1 * ts.size;
let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
y_text += w1 * ts.size + spacing;
continue;
}
if !blank {
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
// A negative font size or `Tz` runs the glyphs backwards:
// the word is the union of its glyphs, whichever way they go.
let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
x_text += w * ts.scale;
}
}
other => {
if let Some(n) = other.as_f64() {
if vertical.is_some() {
// Down by n/1000 em, not scaled by `Tz`.
let dy = -n / 1000.0 * ts.size;
if -dy > ts.size * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
if dy.abs() > ts.size.abs() * 0.2 {
in_word = false;
}
y_text += dy;
continue;
}
let dx = -n / 1000.0 * ts.size * ts.scale;
// A large gap inside TJ reads as a space.
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
// Tracking and kerning keep a word together; a larger jump
// ends it (whatever the sign of the size or `Tz`).
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
in_word = false;
}
x_text += dx;
}
}
}
}
*tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(tm);
let end = tm.then(&ts.ctm).apply(0.0, ts.rise);
// The box from `a` to `b` along the run: from descent to ascent across it, or for
// vertical text, the widest glyph's width centred on the pen.
let span = |a: f64, b: f64| match vertical {
Some(_) => [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)],
None => [
trm0.apply(a, ts.rise + m.descent * ts.size),
trm0.apply(b, ts.rise + m.descent * ts.size),
trm0.apply(a, ts.rise + m.ascent * ts.size),
trm0.apply(b, ts.rise + m.ascent * ts.size),
],
};
let corners = span(0.0, if vertical.is_some() { y_text } else { x_text });
let rect = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
// The same corners along and across the baseline: the run's own box, not its
// upright bounding box turned (which is wider by the box's height on a slant).
let dir = direction(&trm0);
let frame = corners
.iter()
.map(|&p| to_frame(dir, p))
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
let ink = words
.iter()
.map(|&(a, b)| {
span(a, b).iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
})
.collect();
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
// It must not create a phantom editable line or enlarge its neighbours' boxes.
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
return Ok(());
}
out.push(Shown {
op: i,
extra_ops: Vec::new(),
glyph_count,
// Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the
// preview divides by size × `Tz`, so `Tz` is put back here.
advance: if vertical.is_some() { y_text * ts.scale } else { x_text },
tracking: 0.0,
tm: Matrix([
tm.0[0],
tm.0[1],
tm.0[2],
tm.0[3],
tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2],
tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3],
]),
state: ts.clone(),
tlm: *tlm,
bt_op: at_bt.0,
bt_state: at_bt.1.clone(),
text,
rect,
frame,
ink,
baseline: start.1,
start_x: start.0,
end_x: end.0,
end_y: end.1,
bt,
font: name,
base_font: m.base_font.clone(),
face: ts.face.clone(),
size: if size_user > 0.0 { size_user } else { ts.size },
bold: m.bold,
italic: m.italic,
visual_order: m.visual_order,
decodable,
scope: marked.regions.last().copied().flatten(),
paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
logical_order: false,
actual_text: false,
});
Ok(())
}
pub(super) fn interpret(
doc: &Document,
ops: &[Op],
@@ -490,66 +764,17 @@ pub(super) fn interpret(
let mut stack: Vec<Ts> = std::mem::take(&mut carry.stack);
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
let mut bt = 0usize;
let mut marked = std::mem::take(&mut carry.marked);
let mut layers = std::mem::take(&mut carry.layers);
let mut actual: Vec<Option<(usize, usize, String)>> = Vec::new();
let mut marked =
MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() };
for (i, op) in ops.iter().enumerate() {
match op.op.as_slice() {
// No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing
// sequence (#3573). Both stacks stay bounded by the number of operators.
b"BMC" | b"BDC" => {
// Marked text paragraphs/headings are authoritative boundaries. Ignore
// artifacts and inner spans, which inherit their enclosing region.
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
marked.push(if region { Some(i) } else { marked.last().copied().flatten() });
// A layer named in the resources is told apart by its name, an inline one by where it opens.
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
layers.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{i}").into_bytes(), <[u8]>::to_vec)));
// The property list is inline or named in the resources' /Properties (§14.6.2).
let list = logical_text.and_then(|properties| match op.operands.get(1) {
Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)),
other => other.and_then(Object::as_dict).cloned(),
});
let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()),
_ => None,
});
actual.push(logical.map(|s| (out.len(), i, s)));
marked.begin(doc, op, i, logical_text, out.len());
}
b"EMC" => {
marked.pop();
layers.pop();
if let Some(Some((start, open, logical))) = actual.pop() {
let tail = out.split_off(start.min(out.len()));
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for shown in items {
first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count);
first.extra_ops.push(shown.op);
first.extra_ops.extend(shown.extra_ops);
first.rect = [
first.rect[0].min(shown.rect[0]),
first.rect[1].min(shown.rect[1]),
first.rect[2].max(shown.rect[2]),
first.rect[3].max(shown.rect[3]),
];
first.frame = union(first.frame, shown.frame);
first.ink.extend(shown.ink);
}
first.extra_ops.extend([open, i]);
// Only right-to-left ActualText is already in typing order and must stay
// out of the line's bidi recovery. Word and Chrome wrap Latin ligatures
// (`fi`, `fl`) in ActualText spans mid-word; those keep joining their line.
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
first.actual_text = true;
first.text = logical;
first.start_x = first.rect[0];
first.end_x = first.rect[2];
first.end_y = first.baseline;
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
out.push(first);
}
}
marked.end(i, &mut out);
}
b"q" => stack.push(ts.clone()),
b"Q" => {
@@ -650,207 +875,16 @@ pub(super) fn interpret(
tm = tlm;
}
b"Tj" | b"TJ" | b"'" | b"\"" => {
if matches!(op.op.as_slice(), b"'" | b"\"") {
if op.is("\"") {
ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing);
ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing);
}
tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm);
tm = tlm;
}
let Some((name, m)) = ts.font.clone() else { continue };
// Pieces: strings, and TJ number adjustments.
let pieces: Vec<Object> = match op.op.as_slice() {
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still
// takes the one immediately before it, as the renderer and every other
// reader here do (#3569) -- not the first, which a reader counting operands
// from the front of a surplus list would otherwise take.
b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
// Tc is already included in the pen advance; TJ may encode tracking instead.
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
let trm0 = tm.then(&ts.ctm);
let start = trm0.apply(0.0, ts.rise);
let mut text = String::new();
let mut decodable = true;
let mut x_text = 0.0;
// Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred
// on it horizontally; `half` is half the widest glyph.
let vertical = ts.vertical;
let (mut y_text, mut half) = (0.0, 0.0_f64);
// Where each word's glyphs start and end along the run (text space), and whether
// the next glyph continues the last word.
let mut words: Vec<(f64, f64)> = Vec::new();
let mut in_word = false;
let mut glyph_count = 0_usize;
for p in &pieces {
match p {
Object::String(s) => {
for (code, len) in m.codes(&s.bytes) {
glyph_count = glyph_count.saturating_add(1);
let shown = m.text_of(code);
let blank =
m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
match shown {
Some(t) => {
carry.text_left = carry
.text_left
.checked_sub(t.len())
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
text.push_str(&t);
}
None => decodable = false,
}
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
if let Some(w1) = vertical {
half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0);
if !blank {
let ink_end = y_text + w1 * ts.size;
let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
y_text += w1 * ts.size + spacing;
continue;
}
if !blank {
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
// A negative font size or `Tz` runs the glyphs backwards:
// the word is the union of its glyphs, whichever way they go.
let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
x_text += w * ts.scale;
}
}
other => {
if let Some(n) = other.as_f64() {
if vertical.is_some() {
// Down by n/1000 em, not scaled by `Tz`.
let dy = -n / 1000.0 * ts.size;
if -dy > ts.size * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
if dy.abs() > ts.size.abs() * 0.2 {
in_word = false;
}
y_text += dy;
continue;
}
let dx = -n / 1000.0 * ts.size * ts.scale;
// A large gap inside TJ reads as a space.
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
// Tracking and kerning keep a word together; a larger jump
// ends it (whatever the sign of the size or `Tz`).
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
in_word = false;
}
x_text += dx;
}
}
}
}
tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(&tm);
let end = tm.then(&ts.ctm).apply(0.0, ts.rise);
// The box from `a` to `b` along the run: from descent to ascent across it, or for
// vertical text, the widest glyph's width centred on the pen.
let span = |a: f64, b: f64| match vertical {
Some(_) => {
[trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)]
}
None => [
trm0.apply(a, ts.rise + m.descent * ts.size),
trm0.apply(b, ts.rise + m.descent * ts.size),
trm0.apply(a, ts.rise + m.ascent * ts.size),
trm0.apply(b, ts.rise + m.ascent * ts.size),
],
};
let corners = span(0.0, if vertical.is_some() { y_text } else { x_text });
let rect = corners
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
// The same corners along and across the baseline: the run's own box, not its
// upright bounding box turned (which is wider by the box's height on a slant).
let dir = direction(&trm0);
let frame = corners
.iter()
.map(|&p| to_frame(dir, p))
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
let ink = words
.iter()
.map(|&(a, b)| {
span(a, b)
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
})
.collect();
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
// It must not create a phantom editable line or enlarge its neighbours' boxes.
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
continue;
}
out.push(Shown {
op: i,
extra_ops: Vec::new(),
glyph_count,
// Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the
// preview divides by size × `Tz`, so `Tz` is put back here.
advance: if vertical.is_some() { y_text * ts.scale } else { x_text },
tracking: 0.0,
tm: Matrix([
tm.0[0],
tm.0[1],
tm.0[2],
tm.0[3],
tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2],
tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3],
]),
state: ts.clone(),
tlm,
bt_op: at_bt.0,
bt_state: at_bt.1.clone(),
text,
rect,
frame,
ink,
baseline: start.1,
start_x: start.0,
end_x: end.0,
end_y: end.1,
bt,
font: name,
base_font: m.base_font.clone(),
face: ts.face.clone(),
size: if size_user > 0.0 { size_user } else { ts.size },
bold: m.bold,
italic: m.italic,
visual_order: m.visual_order,
decodable,
scope: marked.last().copied().flatten(),
paint: Paint { layers: layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
logical_order: false,
actual_text: false,
});
let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out };
show_text(op, i, &mut state)?;
}
_ => {}
}
}
carry.ts = ts;
carry.stack = stack;
carry.marked = marked;
carry.layers = layers;
carry.marked = marked.regions;
carry.layers = marked.layers;
positioned_tracking(&mut out);
Ok(out)
}
+1 -1
View File
@@ -1,6 +1,6 @@
use super::fallback::{same_font, same_paint};
use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union};
use super::*;
use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops};
impl TextLine {
/// The baseline's height in user space.
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,6 +1,11 @@
//! Geometry-based regions and mixed-style rewriting of existing text.
use super::*;
use super::{
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font,
no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family,
source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs,
};
#[path = "text_regions/grouping.rs"]
mod grouping;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union};
fn same_plane(a: &TextLine, b: &TextLine) -> bool {
let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm));
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -3,7 +3,10 @@
//! another one's output; the commit writes into the document it was opened on, and refuses if
//! that document has changed since.
use super::*;
use super::{
BlockStyle, Document, EditError, LineEdit, MAX_TYPE3_GLYPHS, TextBlock, TextEditLayout, TextLine, group_blocks, layout_draft, rewrite_block_from,
text_lines,
};
/// One paragraph being edited: the document as it was opened, and the page's lines then.
pub struct TextSession {