mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge remote-tracking branch 'upstream/main' into lane7-4496b
This commit is contained in:
commit
46d15f27fd
25 files changed
+1570
-80
No files matched your search
Generated
+1
@@ -4696,6 +4696,7 @@ dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"lopdf",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-geom",
|
||||
|
||||
@@ -9,6 +9,10 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
|
||||
const MAX_PAGES: usize = 1_000;
|
||||
const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_OPERATORS: usize = 100_000;
|
||||
/// What all pages together may decode and keep: pages can share one stream, so the per-page limits
|
||||
/// alone let a small file retain a plan for every page. Real documents stay far under these.
|
||||
const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024;
|
||||
const MAX_TOTAL_OPERATORS: usize = 2_000_000;
|
||||
const MAX_TEXT_OBJECTS: usize = 4_096;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -186,6 +190,7 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
|
||||
}
|
||||
let mut out = Vec::with_capacity(pages.len());
|
||||
let mut total = 0usize;
|
||||
let (mut total_bytes, mut total_ops) = (0usize, 0usize);
|
||||
for (index, page) in pages.iter().enumerate() {
|
||||
if page.dict.contains(b"StructParents") {
|
||||
return Err(unsupported(index, "page already has a structure parent key"));
|
||||
@@ -197,10 +202,15 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
|
||||
return Err(unsupported(index, "annotations need separate structure tags"));
|
||||
}
|
||||
let bytes = content_bytes(doc, page, index)?;
|
||||
total_bytes = total_bytes.saturating_add(bytes.len());
|
||||
let parsed = parse(&bytes);
|
||||
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
|
||||
return Err(unsupported(index, "content has malformed or too many operators"));
|
||||
}
|
||||
total_ops = total_ops.saturating_add(parsed.ops.len());
|
||||
if total_bytes > MAX_TOTAL_BYTES || total_ops > MAX_TOTAL_OPERATORS {
|
||||
return Err(unsupported(index, "the document has too much content to auto-tag in one edit"));
|
||||
}
|
||||
let spans = text_spans(&parsed.ops, index)?;
|
||||
total = total.saturating_add(spans.len());
|
||||
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
|
||||
|
||||
@@ -1763,3 +1763,20 @@ fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() {
|
||||
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge));
|
||||
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowance() {
|
||||
// Each page is just under the per-page limit; sharing one stream makes the total 21 times it.
|
||||
let content = format!("BT (a) Tj ET {}", "q Q ".repeat(49_990));
|
||||
let pages = 21;
|
||||
let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", 4 + i)).collect();
|
||||
let mut bodies = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 200 200] >>", kids.join(" ")),
|
||||
stream(&content),
|
||||
];
|
||||
bodies.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Contents 3 0 R >>".to_string()));
|
||||
let mut doc = pdf(&bodies, "");
|
||||
let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") };
|
||||
assert!(reason.contains("too much content"), "{reason}");
|
||||
}
|
||||
@@ -3213,6 +3213,175 @@ fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() {
|
||||
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join(" "), text);
|
||||
}
|
||||
|
||||
/// #2378: a word the line's subset can't show goes to Helvetica, and only that word. The words
|
||||
/// it can show keep the source font (here `F2`, a subset of `a` and `b` without a space glyph),
|
||||
/// one font per word, and the line reads back whole and in place: one line, in its own font, so
|
||||
/// editing it again replaces all of it (review on #2955), also when it starts with a substitute
|
||||
/// word.
|
||||
#[test]
|
||||
fn only_the_words_a_subset_cannot_show_are_substituted() {
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
let r = text::replace_line(&mut doc, 0, 0, "ab ba cab ba").unwrap();
|
||||
assert_eq!(r.substituted.as_deref(), Some("Helvetica"));
|
||||
let mut doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("/F2") && content.contains("/PCEdHelvetica"), "both fonts are used: {content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect();
|
||||
assert_eq!(read, [("ab ba cab ba", "F2")]);
|
||||
// Each word where its font puts it, the substituted `cab` included: no gap or overlap opens
|
||||
// where the font changes (review on #2955).
|
||||
let starts: Vec<f64> = lines[0].ink.iter().map(|b| b[0]).collect();
|
||||
assert_eq!(starts.len(), 4, "{:?}", lines[0].ink);
|
||||
for (got, want) in starts.iter().zip([72.0, 85.5, 98.48, 117.9]) {
|
||||
assert!((got - want).abs() < 0.01, "word starts {starts:?}, want {want} among them");
|
||||
}
|
||||
assert!(lines[0].ink.windows(2).all(|w| w[0][2] < w[1][0]), "no word box overlaps the next: {:?}", lines[0].ink);
|
||||
for (text, first) in [("cab ab", "PCEdHelvetica"), ("ba cab", "F2")] {
|
||||
text::replace_line(&mut doc, 0, 0, text).unwrap();
|
||||
doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect();
|
||||
assert_eq!(read, [(text, "F2")], "after replacing with {text:?} (starting with {first})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review on #2955: replacing a line written in two fonts again replaces its `/PCEdLine`
|
||||
/// sequence too. Kept, each edit nested one more, and its `EMC`s outnumbered what the reader
|
||||
/// counts once 256 deep, closing the sequence around the line (here a `/Span`). The content
|
||||
/// doesn't grow from one edit to the next.
|
||||
#[test]
|
||||
fn replacing_a_mixed_font_line_again_replaces_its_sequence() {
|
||||
let mut doc = text_page("/Span BMC BT /F2 10 Tf 72 600 Td (ab) Tj ET EMC BT /F2 10 Tf 72 500 Td (ba) Tj ET");
|
||||
let count = |content: &str, op: &str| content.split_whitespace().filter(|w| *w == op).count();
|
||||
let mut sizes = Vec::new();
|
||||
for text in ["ab ba cab ba", "cab ab", "ab cab", "ab cab", "ab ba", "ba ab"] {
|
||||
text::replace_line(&mut doc, 0, 0, text).unwrap();
|
||||
doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
let mixed = text.contains('c');
|
||||
assert_eq!(content.matches("/PCEdLine").count(), usize::from(mixed), "after {text:?}: {content}");
|
||||
assert_eq!((count(&content, "BMC"), count(&content, "EMC")), (1 + usize::from(mixed), 1 + usize::from(mixed)), "after {text:?}: {content}");
|
||||
assert_eq!(count(&content, "Tf"), 2 + if mixed { 4 } else { 1 }, "after {text:?}: {content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<&str> = lines.iter().map(|l| l.text.trim()).collect();
|
||||
assert_eq!(read, [text, "ba"], "after {text:?}");
|
||||
assert_eq!(lines[0].baseline(), [72.0, 600.0], "after {text:?}: in place");
|
||||
sizes.push(content.split_whitespace().count());
|
||||
}
|
||||
assert_eq!(sizes[2], sizes[3], "the same text again, the same content");
|
||||
// A sequence holding marked content of its own goes whole, and what follows it keeps its
|
||||
// font and size unless the replacement sets the same again.
|
||||
for (after, font, size) in [("/F1 12 Tf", "F1", 12.0), ("/F2 12 Tf", "F2", 12.0), ("/F1 10 Tf", "F1", 10.0)] {
|
||||
let mut doc = text_page(&format!(
|
||||
"BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf /Span <<>> BDC (ab) Tj EMC /F1 10 Tf ( cab) Tj EMC {after} 0 -20 Td (ba) Tj ET"
|
||||
));
|
||||
text::replace_line(&mut doc, 0, 0, "ab ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert_eq!((count(&content, "BMC"), count(&content, "BDC"), count(&content, "EMC")), (0, 0, 0), "{after}: {content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<(&str, &str, f64)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str(), l.size)).collect();
|
||||
assert_eq!(read, [("ab ab", "F2", 10.0), ("ba", font, size)], "{after}: {content}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review on #2955: a leading space is not a word the line's font shows. " cab" (no word the
|
||||
/// subset can show) was split into an empty source-font run and a substitute run, so it was
|
||||
/// written as a two-font `/PCEdLine` with an empty `Tj` in the line's font; read back, the line
|
||||
/// was the substitute's alone, and the next edit took Helvetica for the line's own font. It is
|
||||
/// substituted as a whole instead, as a line with no word in the source font always was.
|
||||
#[test]
|
||||
fn a_leading_space_does_not_make_a_source_font_run() {
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " cab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(!content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}");
|
||||
// With a word in the source font after it, the line is still written in both.
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " ab cab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("ab cab", "F2")]);
|
||||
// The spaces the subset has no glyph for are moves, each one kept: the leading one before
|
||||
// the first word, two between words as two.
|
||||
let flat = |doc: &Document| String::from_utf8_lossy(&page_content_bytes(doc, 0)).split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(flat(&doc).contains("/F2 10 Tf [-330] TJ (ab) Tj"), "{}", flat(&doc));
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, "ab ba cab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert!(flat(&doc).contains("(ab) Tj [-330] TJ [-330] TJ (ba) Tj"), "{}", flat(&doc));
|
||||
// Before a substitute word, the leading spaces are the substitute's to show, all of them.
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " cab ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("( cab) Tj"), "{content}");
|
||||
}
|
||||
|
||||
/// Review on #2955: a paragraph rewrite keeps the `/PCEdLine` delimiters of a line an earlier
|
||||
/// replacement wrote in two fonts, so a rewritten paragraph's rows can end up inside one. A
|
||||
/// later replacement of one of those rows must not take the sequence's other rows with it.
|
||||
#[test]
|
||||
fn replacing_one_row_of_a_sequence_keeps_the_other_rows() {
|
||||
let content = "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj 0 -20 Td /F2 10 Tf (ba) Tj EMC ET";
|
||||
let rows = text::text_lines(&text_page(content), 0).unwrap();
|
||||
assert_eq!(rows.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["ab cab", "ba"]);
|
||||
for (row, text, other) in [(0, "ab ab", "ba"), (1, "ab", "ab cab")] {
|
||||
let mut doc = text_page(content);
|
||||
text::replace_line(&mut doc, 0, row, text).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let after: Vec<String> = text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.trim().to_string()).collect();
|
||||
assert!(after.iter().any(|t| t == text) && after.iter().any(|t| t == other), "replacing row {row}: {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review on #2955: a row inside a shared `/PCEdLine` sequence that starts with a substitute run
|
||||
/// has the substitute's font in effect at its first operator. Replaced by text the row's own font
|
||||
/// shows, it must set that font again, or the new text is drawn in the substitute.
|
||||
#[test]
|
||||
fn a_row_replaced_inside_a_shared_sequence_starts_in_its_own_font() {
|
||||
// A real two-font line, starting with the substitute run, then a second row put inside its
|
||||
// sequence, as a paragraph rewrite that keeps the delimiters can leave it.
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " cab ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("/PCEdLine") && content.contains("( cab) Tj"), "{content}");
|
||||
let mut doc = doc;
|
||||
let shared = content.replacen("EMC", "0 -20 Td (ba) Tj EMC", 1);
|
||||
let stream = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), shared.into_bytes()))));
|
||||
set_page_contents(&mut doc, stream, None);
|
||||
let rows = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(rows.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("cab ab", "F2"), ("ba", "F2")]);
|
||||
text::replace_line(&mut doc, 0, 0, "ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let after = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(after.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("ab", "F2"), ("ba", "F2")]);
|
||||
}
|
||||
|
||||
/// Review on #2955: a sequence holding one row can also hold that row's positioning and text
|
||||
/// state, as a paragraph rewrite writes it inside kept delimiters. Replacing the row drops the
|
||||
/// delimiters, fonts and text, not the `Tm` that places it.
|
||||
#[test]
|
||||
fn replacing_the_only_row_of_a_sequence_keeps_its_position() {
|
||||
let content = "BT /F2 10 Tf /PCEdLine BMC 1 0 0 1 72 600 Tm /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj EMC ET";
|
||||
let mut doc = text_page(content);
|
||||
let before = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(before.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["ab cab"]);
|
||||
text::replace_line(&mut doc, 0, 0, "ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(!content.contains("PCEdLine") && !content.contains("cab"), "{content}");
|
||||
let after = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(after.len(), 1, "{content}");
|
||||
let (was, now) = (before[0].rect, after[0].rect);
|
||||
assert!((was[0] - now[0]).abs() < 0.01 && (was[1] - now[1]).abs() < 0.01, "moved from {was:?} to {now:?}: {content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
//! 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.
|
||||
//! The new text uses the line's own font when every character has a code and a glyph in it.
|
||||
//! Otherwise the words the font can show keep it and only the others are set in Helvetica
|
||||
//! (WinAnsi), run by run, 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;
|
||||
@@ -132,6 +133,9 @@ pub struct TextLine {
|
||||
/// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none
|
||||
/// (#2529). Meaningless otherwise.
|
||||
soft_gap: usize,
|
||||
/// The `/PCEdLine` marked-content sequence a replacement mixing fonts wrote the line in
|
||||
/// (see [`fallback::continues_font`]).
|
||||
edited: Option<usize>,
|
||||
}
|
||||
|
||||
impl TextLine {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::interp::Ts;
|
||||
use super::interp::{Shown, Ts};
|
||||
use super::lines::page_dict;
|
||||
use super::{
|
||||
CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse,
|
||||
@@ -148,6 +148,23 @@ fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option<std::col
|
||||
Some(shown)
|
||||
}
|
||||
|
||||
/// Whether run `s` may continue `line` font-wise: the same font, or both inside the `/PCEdLine`
|
||||
/// sequence of a replacement that set the words its font can't show in a substitute (#2378),
|
||||
/// which reads back as the one line it replaced, so the next edit replaces all of it.
|
||||
pub(super) fn continues_font(line: &TextLine, s: &Shown) -> bool {
|
||||
same_font(line, &s.font, &s.base_font, s.face.as_deref()) || (s.edited.is_some() && line.edited == s.edited)
|
||||
}
|
||||
|
||||
/// A line that began with a substitute run takes the source font of a later run, so the next
|
||||
/// replacement starts from the line's own font again.
|
||||
pub(super) fn adopt_source_font(line: &mut TextLine, s: &Shown) {
|
||||
if line.edited.is_some() && line.font.starts_with("PCEd") && !s.font.starts_with(b"PCEd") {
|
||||
line.font = String::from_utf8_lossy(&s.font).into_owned();
|
||||
line.base_font = s.base_font.clone();
|
||||
line.face = s.face.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese
|
||||
/// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another
|
||||
/// resource for the same font (a writer may split one font into several subsets, #3661), or
|
||||
@@ -730,6 +747,30 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `text` split into runs of words `metrics` can show (`true`) and words it can't (`false`), in
|
||||
/// order. A word is shown in one font, so a word with any character the font lacks goes to the
|
||||
/// substitute whole rather than switching fonts letter by letter. Spaces join the run they are
|
||||
/// in, since [`source_show`] draws spaces the font lacks with a displacement.
|
||||
pub(super) fn source_runs(metrics: &Metrics, text: &str) -> Vec<(bool, String)> {
|
||||
let mut runs: Vec<(bool, String)> = Vec::new();
|
||||
// Spaces before the first word go with it: on their own they would make a run of the line's
|
||||
// font with nothing to show (review on #2955).
|
||||
let mut lead = String::new();
|
||||
for (i, word) in text.split(' ').enumerate() {
|
||||
let piece = if i == 0 { word.to_string() } else { format!(" {word}") };
|
||||
if runs.is_empty() && word.is_empty() {
|
||||
lead.push_str(&piece);
|
||||
continue;
|
||||
}
|
||||
let own = word.is_empty() || metrics.encode(word).is_some();
|
||||
match runs.last_mut() {
|
||||
Some((last, run)) if *last == own || word.is_empty() => run.push_str(&piece),
|
||||
_ => runs.push((own, std::mem::take(&mut lead) + &piece)),
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -359,6 +359,7 @@ pub(super) struct Shown {
|
||||
/// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer),
|
||||
/// matching the single space a rejoin always used before #2529.
|
||||
pub(super) soft_gap: usize,
|
||||
pub(super) edited: Option<usize>,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
@@ -537,6 +538,8 @@ type Invocation = (usize, Vec<u8>, Ts);
|
||||
struct MarkedContent {
|
||||
regions: Vec<Option<usize>>,
|
||||
layers: Vec<Option<Vec<u8>>>,
|
||||
// The `/PCEdLine` sequences around the text (a replacement mixing fonts), innermost last.
|
||||
edited: Vec<Option<usize>>,
|
||||
actual_text: Vec<Option<(usize, usize, String)>>,
|
||||
}
|
||||
|
||||
@@ -550,6 +553,7 @@ impl MarkedContent {
|
||||
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)));
|
||||
self.edited.push(if op.name(0) == Some(b"PCEdLine") { Some(index) } else { self.edited.last().copied().flatten() });
|
||||
// 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)),
|
||||
@@ -565,6 +569,7 @@ impl MarkedContent {
|
||||
fn end(&mut self, index: usize, shown: &mut Vec<Shown>) {
|
||||
self.regions.pop();
|
||||
self.layers.pop();
|
||||
self.edited.pop();
|
||||
if let Some(Some((start, open, logical))) = self.actual_text.pop() {
|
||||
let tail = shown.split_off(start.min(shown.len()));
|
||||
if tail.iter().any(|s| s.glyph_count > 0) {
|
||||
@@ -814,6 +819,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
|
||||
soft_break: state.soft_break,
|
||||
hard_break: state.hard_break,
|
||||
soft_gap: state.soft_gap,
|
||||
edited: marked.edited.last().copied().flatten(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::fallback::{same_font, same_paint};
|
||||
use super::fallback::{adopt_source_font, continues_font, same_paint};
|
||||
use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union};
|
||||
use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops};
|
||||
|
||||
@@ -340,7 +340,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
|
||||
piece == si
|
||||
&& lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order)
|
||||
&& (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s)))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref())))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s)))
|
||||
&& (s.text.trim().is_empty()
|
||||
|| lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01
|
||||
@@ -374,6 +374,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
|
||||
if l.unreadable_mode.is_none() {
|
||||
l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned);
|
||||
}
|
||||
adopt_source_font(l, &s);
|
||||
// Spaces may use a separate subset font; they do not change the visible style.
|
||||
} else {
|
||||
lines.push(TextLine {
|
||||
@@ -401,6 +402,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
|
||||
soft_break: s.soft_break,
|
||||
hard_break: s.hard_break,
|
||||
soft_gap: s.soft_gap,
|
||||
edited: s.edited,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
@@ -461,7 +463,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
let bt = pen.bt;
|
||||
lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order)
|
||||
&& (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s)))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref())))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s)))
|
||||
&& (s.text.trim().is_empty()
|
||||
|| lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01
|
||||
@@ -492,6 +494,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
if l.unreadable_mode.is_none() {
|
||||
l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned);
|
||||
}
|
||||
adopt_source_font(l, &s);
|
||||
} else {
|
||||
lines.push(TextLine {
|
||||
text: s.text.clone(),
|
||||
@@ -518,6 +521,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
soft_break: s.soft_break,
|
||||
hard_break: s.hard_break,
|
||||
soft_gap: s.soft_gap,
|
||||
edited: s.edited,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap};
|
||||
use super::fallback::{
|
||||
Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font,
|
||||
source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
|
||||
source_encodes, source_family, source_program_completes, source_runs, source_show, source_space, source_width, type3_encode, type3_font,
|
||||
};
|
||||
use super::interp::{Carry, Origin, TextState, interpret};
|
||||
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
|
||||
@@ -110,10 +110,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
substituted = Some(format!("{} Type3", fallback.family));
|
||||
} else {
|
||||
// The words the line's font can show keep it; only the others are set in the
|
||||
// substitute (#2378): a subset without `V` or `ü` no longer turns the whole line
|
||||
// into Helvetica. Reordered text (right to left, Indic) keeps one run as before.
|
||||
let mixed = font
|
||||
.as_ref()
|
||||
.filter(|_| !visual_indic && fallback_shown == text)
|
||||
.map(|m| source_runs(m, &text))
|
||||
.filter(|runs| runs.iter().any(|(own, _)| *own));
|
||||
// What the substitute has to show: only the runs the line's font can't.
|
||||
let for_substitute: String = match &mixed {
|
||||
Some(runs) => runs.iter().filter(|(own, _)| !*own).map(|(_, run)| run.as_str()).collect(),
|
||||
None => fallback_shown.clone(),
|
||||
};
|
||||
let win = pdfcraft_fonts::win_ansi(&fallback_shown);
|
||||
// WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing.
|
||||
let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
|
||||
if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
|
||||
let checked = pdfcraft_fonts::win_ansi(&for_substitute);
|
||||
let back: String = checked.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
|
||||
if for_substitute.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
|
||||
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font)));
|
||||
}
|
||||
// The size in text space: the current Tf's size.
|
||||
@@ -121,8 +135,36 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
let family = source_family(&target.base_font);
|
||||
let base = family.base_font(target.bold, target.italic);
|
||||
let substitute_name = format!("PCEd{}", base.replace('-', ""));
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
|
||||
match mixed {
|
||||
Some(runs) => {
|
||||
let space = font.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m));
|
||||
// Marked as one line, so it reads back as one (see `continues_font`).
|
||||
replacement.push(Op::new("BMC", vec![Object::name("PCEdLine")]));
|
||||
for (own, run) in runs {
|
||||
if own {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
if let Some(m) = font.as_ref() {
|
||||
replacement.extend(source_show(
|
||||
m,
|
||||
&run,
|
||||
target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size),
|
||||
space,
|
||||
target.origin.state.char_spacing,
|
||||
target.origin.state.word_spacing,
|
||||
)?);
|
||||
}
|
||||
} else {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(pdfcraft_fonts::win_ansi(&run)))]));
|
||||
}
|
||||
}
|
||||
replacement.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
None => {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
|
||||
}
|
||||
}
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
substituted = Some(base.to_string());
|
||||
let mut f = Dict::new();
|
||||
@@ -135,6 +177,43 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
}
|
||||
}
|
||||
// Rebuild: the line's first operator becomes the replacement, its others go.
|
||||
// A line an earlier replacement wrote in two fonts is in a `/PCEdLine` sequence of its own,
|
||||
// with its fonts and the moves standing in for spaces the source font lacks: those go
|
||||
// with the line, or each edit would nest one more (review on #2955). The line then starts in
|
||||
// its own font, set again where the sequence was.
|
||||
if let Some((open, close)) = target.edited.and_then(|open| Some((open, matching_emc(&ops, open)?))) {
|
||||
// Only when the sequence holds nothing but this line: a paragraph rewrite keeps the
|
||||
// delimiters, so other rows can be inside it (review on #2955). Then the sequence stays
|
||||
// as it is, since it is also what reads its own two-font line back as one, and only
|
||||
// this line's operators go.
|
||||
let others_inside = lines.iter().any(|l| l.ops.iter().any(|o| (open..=close).contains(o) && !drops.contains(o)));
|
||||
// Either way the replacement starts in the line's own font: inside the sequence the font
|
||||
// in effect at the line's first operator can be the substitute's, which a replacement
|
||||
// the line's font shows by itself would otherwise inherit (review on #2955).
|
||||
let size = font_size_before(&ops, first).unwrap_or(target.size);
|
||||
replacement.insert(0, Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
if !others_inside {
|
||||
// What goes is what the replacement stands in for: the marked-content delimiters
|
||||
// (nested ones too, which come in pairs), the fonts and the text-showing operators
|
||||
// (the line's runs and the moves between them). A paragraph rewrite can also write a
|
||||
// row's positioning and text state (`Tm`, `Td`, `Tw`, ...), a `q`/`Q` or a `BT`/`ET`
|
||||
// inside kept delimiters; those stay, or the replacement loses its place and the
|
||||
// content its balance (review on #2955).
|
||||
drops.extend(
|
||||
(open..=close)
|
||||
.filter(|&i| ops.get(i).is_some_and(|o| [b"BMC".as_slice(), b"BDC", b"EMC", b"Tf", b"Tj", b"TJ"].contains(&o.op.as_slice()))),
|
||||
);
|
||||
let own_font = |i: usize| ops.get(i).is_some_and(|o| o.is("Tf") && o.name(0) == Some(target.font.as_bytes()) && o.num(1) == Some(size));
|
||||
// The same font set just before the sequence (by the last edit) is set again here.
|
||||
if let Some(i) = open.checked_sub(1).filter(|&i| own_font(i)) {
|
||||
drops.insert(i);
|
||||
}
|
||||
// And set back after it: the replacement ends in that font too.
|
||||
if let Some(i) = close.checked_add(1).filter(|&i| own_font(i)) {
|
||||
drops.insert(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
|
||||
let contents = rewritten_contents(doc, &streams, new_data)?;
|
||||
let page_ref = p.obj;
|
||||
@@ -150,6 +229,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
Ok(LineEdit { substituted })
|
||||
}
|
||||
|
||||
/// The `EMC` that closes the marked-content sequence `BMC`/`BDC` operator `open` begins.
|
||||
fn matching_emc(ops: &[Op], open: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
for (i, op) in ops.iter().enumerate().skip(open) {
|
||||
match op.op.as_slice() {
|
||||
b"BMC" | b"BDC" => depth = depth.saturating_add(1),
|
||||
b"EMC" => {
|
||||
depth = depth.checked_sub(1)?;
|
||||
if depth == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The size of the last `Tf` before operator `at`.
|
||||
fn font_size_before(ops: &[Op], at: usize) -> Option<f64> {
|
||||
ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1))
|
||||
|
||||
+277
-39
@@ -1,4 +1,6 @@
|
||||
//! A minimal zip writer (deflate or store, no zip64) for Office files.
|
||||
//! A minimal zip writer (deflate or store) for Office files. Archives that outgrow the classic
|
||||
//! end-of-central-directory record's 16-bit entry count or 32-bit sizes are written with the
|
||||
//! ZIP64 records (APPNOTE 4.4), which every reader understands; small ones are unchanged.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
@@ -22,12 +24,102 @@ fn crc32(data: &[u8]) -> u32 {
|
||||
c ^ 0xFFFF_FFFF
|
||||
}
|
||||
|
||||
/// Builds a zip archive in memory.
|
||||
/// The largest entry count the classic end-of-central-directory record holds (APPNOTE 4.3.12).
|
||||
const MAX_CLASSIC_COUNT: u64 = u16::MAX as u64;
|
||||
/// The largest size or offset the classic records hold: past it the field is a sentinel and the
|
||||
/// ZIP64 record carries the real value (APPNOTE 4.4.1).
|
||||
const MAX_CLASSIC_U32: u64 = u32::MAX as u64;
|
||||
|
||||
/// The ZIP64 extended-information extra field (APPNOTE 4.5.3) holding `fields` — the values that
|
||||
/// no longer fit their classic fields — in specification order. Empty when everything fits.
|
||||
fn zip64_extra(fields: &[u64]) -> Vec<u8> {
|
||||
let mut e = Vec::with_capacity(4 + 8 * fields.len());
|
||||
if !fields.is_empty() {
|
||||
e.extend(0x0001u16.to_le_bytes());
|
||||
e.extend((8 * fields.len() as u16).to_le_bytes());
|
||||
for &f in fields {
|
||||
e.extend(f.to_le_bytes());
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// The classic 32-bit field value for `value`: the value itself, or the 0xFFFFFFFF sentinel when it
|
||||
/// no longer fits — including when it equals the sentinel, which a reader cannot tell from a real
|
||||
/// value — in which case the ZIP64 extra field carries the real value (APPNOTE 4.4.3).
|
||||
fn classic_u32(value: u64) -> u32 {
|
||||
if value >= MAX_CLASSIC_U32 { u32::MAX } else { value as u32 }
|
||||
}
|
||||
|
||||
/// Builds one member's local file header and central directory entry. The sizes and `offset` are
|
||||
/// parameters, not read from the body, so tests can inject values past the classic fields without
|
||||
/// allocating the data.
|
||||
fn entry_headers(name: &str, crc: u32, method: u16, compressed: u64, uncompressed: u64, offset: u64) -> (Vec<u8>, Vec<u8>) {
|
||||
// A value at or above the sentinel cannot be held by a classic field, so it is offloaded.
|
||||
let size_is_zip64 = uncompressed >= MAX_CLASSIC_U32 || compressed >= MAX_CLASSIC_U32;
|
||||
// A local header that carries ZIP64 sizes carries both, original then compressed, even when only
|
||||
// one overflowed (APPNOTE 4.5.3). A central entry carries one value for each field it set to its
|
||||
// sentinel, and only those, so a reader walking the fields by their classic ones stays in step.
|
||||
let local_extra = if size_is_zip64 { zip64_extra(&[uncompressed, compressed]) } else { Vec::new() };
|
||||
let mut central_fields = Vec::new();
|
||||
if uncompressed >= MAX_CLASSIC_U32 {
|
||||
central_fields.push(uncompressed);
|
||||
}
|
||||
if compressed >= MAX_CLASSIC_U32 {
|
||||
central_fields.push(compressed);
|
||||
}
|
||||
if offset >= MAX_CLASSIC_U32 {
|
||||
central_fields.push(offset);
|
||||
}
|
||||
let central_extra = zip64_extra(¢ral_fields);
|
||||
// An entry that carries ZIP64 fields needs version 4.5 to extract (APPNOTE 4.4.3); the version
|
||||
// in the local header and the central entry agree.
|
||||
let version_needed = if size_is_zip64 || offset >= MAX_CLASSIC_U32 { 45u16 } else { 20u16 };
|
||||
let header = |sig: u32, central: bool| {
|
||||
let mut h = Vec::new();
|
||||
h.extend(sig.to_le_bytes());
|
||||
if central {
|
||||
h.extend(20u16.to_le_bytes()); // made by
|
||||
}
|
||||
h.extend(version_needed.to_le_bytes()); // version needed
|
||||
h.extend(0x0800u16.to_le_bytes()); // UTF-8 names
|
||||
h.extend(method.to_le_bytes());
|
||||
h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date)
|
||||
h.extend(crc.to_le_bytes());
|
||||
// A local ZIP64 extra carries both sizes, so both corresponding classic fields are
|
||||
// sentinels even when only one size needs offloading (APPNOTE 4.5.3). Central entries
|
||||
// remain selective and use a sentinel only for fields represented in their extra.
|
||||
let (compressed_field, uncompressed_field) =
|
||||
if !central && size_is_zip64 { (u32::MAX, u32::MAX) } else { (classic_u32(compressed), classic_u32(uncompressed)) };
|
||||
h.extend(compressed_field.to_le_bytes());
|
||||
h.extend(uncompressed_field.to_le_bytes());
|
||||
h.extend((name.len() as u16).to_le_bytes());
|
||||
let extra = if central { ¢ral_extra } else { &local_extra };
|
||||
h.extend((extra.len() as u16).to_le_bytes()); // extra
|
||||
if central {
|
||||
h.extend(0u16.to_le_bytes()); // comment
|
||||
h.extend(0u16.to_le_bytes()); // disk
|
||||
h.extend(0u16.to_le_bytes()); // internal attributes
|
||||
h.extend(0u32.to_le_bytes()); // external attributes
|
||||
h.extend(classic_u32(offset).to_le_bytes());
|
||||
}
|
||||
h.extend(name.as_bytes());
|
||||
h.extend(extra);
|
||||
h
|
||||
};
|
||||
(header(0x0403_4b50, false), header(0x0201_4b50, true))
|
||||
}
|
||||
|
||||
/// Builds a zip archive in memory. An archive past the classic record's 16-bit entry count or
|
||||
/// 32-bit sizes and offsets is written with a ZIP64 end-of-central-directory record: a
|
||||
/// PowerPoint export writes two entries per page plus ten fixed parts, so a document with
|
||||
/// 32 763 pages already passes 65 535 entries, and without the ZIP64 record the count wraps
|
||||
/// and readers see a central directory that does not describe the archive.
|
||||
#[derive(Default)]
|
||||
pub struct Zip {
|
||||
out: Vec<u8>,
|
||||
central: Vec<u8>,
|
||||
count: u16,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl Zip {
|
||||
@@ -41,49 +133,44 @@ impl Zip {
|
||||
} else {
|
||||
(0u16, data.to_vec())
|
||||
};
|
||||
let offset = self.out.len() as u32;
|
||||
let header = |sig: u32, central: bool| {
|
||||
let mut h = Vec::new();
|
||||
h.extend(sig.to_le_bytes());
|
||||
if central {
|
||||
h.extend(20u16.to_le_bytes()); // made by
|
||||
}
|
||||
h.extend(20u16.to_le_bytes()); // version needed
|
||||
h.extend(0x0800u16.to_le_bytes()); // UTF-8 names
|
||||
h.extend(method.to_le_bytes());
|
||||
h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date)
|
||||
h.extend(crc.to_le_bytes());
|
||||
h.extend((body.len() as u32).to_le_bytes());
|
||||
h.extend((data.len() as u32).to_le_bytes());
|
||||
h.extend((name.len() as u16).to_le_bytes());
|
||||
h.extend(0u16.to_le_bytes()); // extra
|
||||
if central {
|
||||
h.extend(0u16.to_le_bytes()); // comment
|
||||
h.extend(0u16.to_le_bytes()); // disk
|
||||
h.extend(0u16.to_le_bytes()); // internal attributes
|
||||
h.extend(0u32.to_le_bytes()); // external attributes
|
||||
h.extend(offset.to_le_bytes());
|
||||
}
|
||||
h.extend(name.as_bytes());
|
||||
h
|
||||
};
|
||||
self.out.extend(header(0x0403_4b50, false));
|
||||
let offset = self.out.len() as u64;
|
||||
let (local, central) = entry_headers(name, crc, method, body.len() as u64, data.len() as u64, offset);
|
||||
self.out.extend(local);
|
||||
self.out.extend(&body);
|
||||
self.central.extend(header(0x0201_4b50, true));
|
||||
self.central.extend(central);
|
||||
self.count += 1;
|
||||
}
|
||||
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
let start = self.out.len() as u32;
|
||||
let size = self.central.len() as u32;
|
||||
let start = self.out.len() as u64;
|
||||
let size = self.central.len() as u64;
|
||||
let count = self.count as u64;
|
||||
self.out.append(&mut self.central);
|
||||
let classic_count = if count > MAX_CLASSIC_COUNT { u16::MAX } else { count as u16 };
|
||||
if count > MAX_CLASSIC_COUNT || size >= MAX_CLASSIC_U32 || start >= MAX_CLASSIC_U32 {
|
||||
let eocd64_at = self.out.len() as u64;
|
||||
self.out.extend(0x0606_4b50u32.to_le_bytes()); // ZIP64 end of central directory record
|
||||
self.out.extend(44u64.to_le_bytes()); // size of the rest of this record
|
||||
self.out.extend(45u16.to_le_bytes()); // version made by (4.5, DOS)
|
||||
self.out.extend(45u16.to_le_bytes()); // version needed to extract
|
||||
self.out.extend(0u32.to_le_bytes()); // number of this disk
|
||||
self.out.extend(0u32.to_le_bytes()); // disk with the start of the central directory
|
||||
self.out.extend(count.to_le_bytes()); // entries on this disk
|
||||
self.out.extend(count.to_le_bytes()); // entries in total
|
||||
self.out.extend(size.to_le_bytes()); // size of the central directory
|
||||
self.out.extend(start.to_le_bytes()); // offset of the central directory
|
||||
self.out.extend(0x0706_4b50u32.to_le_bytes()); // ZIP64 end of central directory locator
|
||||
self.out.extend(0u32.to_le_bytes()); // disk with the ZIP64 record
|
||||
self.out.extend(eocd64_at.to_le_bytes()); // offset of the ZIP64 record
|
||||
self.out.extend(1u32.to_le_bytes()); // total number of disks
|
||||
}
|
||||
self.out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
self.out.extend([0u8; 4]);
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend(size.to_le_bytes());
|
||||
self.out.extend(start.to_le_bytes());
|
||||
self.out.extend(0u16.to_le_bytes());
|
||||
self.out.extend([0u8; 4]); // number of this disk, disk with the central directory
|
||||
self.out.extend(classic_count.to_le_bytes()); // entries on this disk
|
||||
self.out.extend(classic_count.to_le_bytes()); // entries in total
|
||||
self.out.extend(classic_u32(size).to_le_bytes());
|
||||
self.out.extend(classic_u32(start).to_le_bytes());
|
||||
self.out.extend(0u16.to_le_bytes()); // comment length
|
||||
self.out
|
||||
}
|
||||
}
|
||||
@@ -107,4 +194,155 @@ mod tests {
|
||||
assert_eq!(b.windows(4).filter(|w| *w == b"PK\x01\x02").count(), 2);
|
||||
assert_eq!(&b[b.len() - 22..b.len() - 18], b"PK\x05\x06");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_entries_than_the_classic_record_holds_writes_zip64() {
|
||||
// A PowerPoint export writes two entries per page plus ten fixed parts, so a
|
||||
// document with 32 763 pages already passes the classic record's 16-bit entry
|
||||
// count. The count must not wrap: it overflows in a debug build, and in a release
|
||||
// one it silently writes a central directory readers cannot match to the archive.
|
||||
let mut z = Zip::default();
|
||||
for i in 0..u16::MAX as usize + 1 {
|
||||
z.add(&format!("ppt/slides/slide{i}.xml"), b"x", true);
|
||||
z.add(&format!("ppt/slides/_rels/slide{i}.xml.rels"), b"y", true);
|
||||
}
|
||||
let b = z.finish();
|
||||
let entries = 2 * (u16::MAX as usize + 1);
|
||||
// The classic end-of-central-directory record holds sentinels…
|
||||
let eocd = &b[b.len() - 22..];
|
||||
assert_eq!(&eocd[..4], &0x0605_4b50u32.to_le_bytes());
|
||||
assert_eq!(&eocd[8..10], &u16::MAX.to_le_bytes(), "entries on this disk");
|
||||
assert_eq!(&eocd[10..12], &u16::MAX.to_le_bytes(), "entries in total");
|
||||
// …and the ZIP64 locator and record carry the truth.
|
||||
let locator = &b[b.len() - 42..b.len() - 22];
|
||||
assert_eq!(&locator[..4], &0x0706_4b50u32.to_le_bytes());
|
||||
let at = u64::from_le_bytes(locator[8..16].try_into().unwrap_or([0; 8])) as usize;
|
||||
assert_eq!(&b[at..at + 4], &0x0606_4b50u32.to_le_bytes());
|
||||
assert_eq!(u64::from_le_bytes(b[at + 24..at + 32].try_into().unwrap_or([0; 8])), entries as u64, "entries on this disk");
|
||||
assert_eq!(u64::from_le_bytes(b[at + 32..at + 40].try_into().unwrap_or([0; 8])), entries as u64, "entries in total");
|
||||
}
|
||||
|
||||
/// The extra field of a local file header (APPNOTE 4.3.7), which follows the file name.
|
||||
fn local_extra(h: &[u8]) -> &[u8] {
|
||||
let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize);
|
||||
let (name_len, extra_len) = (le(26, 2), le(28, 2));
|
||||
&h[30 + name_len..30 + name_len + extra_len]
|
||||
}
|
||||
|
||||
/// The extra field of a central directory entry (APPNOTE 4.3.12), which follows the file name.
|
||||
fn central_extra(h: &[u8]) -> &[u8] {
|
||||
let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize);
|
||||
let (name_len, extra_len) = (le(28, 2), le(30, 2));
|
||||
&h[46 + name_len..46 + name_len + extra_len]
|
||||
}
|
||||
|
||||
/// The classic compressed and original size fields of a local file header (`at` 18) or a
|
||||
/// central directory entry (`at` 20).
|
||||
fn classic_sizes(h: &[u8], at: usize) -> (u32, u32) {
|
||||
let field = |i: usize| u32::from_le_bytes(h[i..i + 4].try_into().unwrap_or([0; 4]));
|
||||
(field(at), field(at + 4))
|
||||
}
|
||||
|
||||
/// Asserts the local header carries both sizes whenever either needs ZIP64, with both classic
|
||||
/// size fields set to the sentinel (APPNOTE 4.5.3: a ZIP64 value appears only for a field set to
|
||||
/// its sentinel, and the local entry must carry both sizes). The central entry stays selective:
|
||||
/// only the fields that themselves overflow get a value, in specification order.
|
||||
fn assert_local_pair_and_central_selectivity(compressed: u64, uncompressed: u64) {
|
||||
let at_limit = |v: u64| v >= u32::MAX as u64;
|
||||
let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0);
|
||||
assert_eq!(classic_sizes(&local, 18), (u32::MAX, u32::MAX), "both local size fields are sentinels for ({compressed}, {uncompressed})");
|
||||
let extra = local_extra(&local);
|
||||
assert_eq!(
|
||||
u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])),
|
||||
16,
|
||||
"both local sizes are present for ({compressed}, {uncompressed})"
|
||||
);
|
||||
assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first");
|
||||
assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second");
|
||||
let expected =
|
||||
(if at_limit(compressed) { u32::MAX } else { compressed as u32 }, if at_limit(uncompressed) { u32::MAX } else { uncompressed as u32 });
|
||||
assert_eq!(
|
||||
classic_sizes(¢ral, 20),
|
||||
expected,
|
||||
"the central entry offloads only the fields that overflow for ({compressed}, {uncompressed})"
|
||||
);
|
||||
let fields = usize::from(at_limit(uncompressed)) + usize::from(at_limit(compressed));
|
||||
let extra = central_extra(¢ral);
|
||||
assert_eq!(
|
||||
u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])),
|
||||
8 * fields as u16,
|
||||
"one central value per sentinel field for ({compressed}, {uncompressed})"
|
||||
);
|
||||
if at_limit(uncompressed) {
|
||||
assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first");
|
||||
}
|
||||
if at_limit(compressed) {
|
||||
let at = if at_limit(uncompressed) { 12 } else { 4 };
|
||||
assert_eq!(u64::from_le_bytes(extra[at..at + 8].try_into().unwrap_or([0; 8])), compressed, "compressed size in specification order");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_local_sizes_are_sentinels_when_only_one_needs_zip64() {
|
||||
// A local ZIP64 extra must carry both sizes, and its values correspond to classic fields set
|
||||
// to their sentinel — so a small classic size next to an overflowing one is wrong even
|
||||
// though the extra holds the truth. Both orders of which size overflows.
|
||||
assert_local_pair_and_central_selectivity(100, u32::MAX as u64 + 1);
|
||||
assert_local_pair_and_central_selectivity(u32::MAX as u64 + 1, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_local_sizes_are_sentinels_when_one_equals_the_sentinel() {
|
||||
// A size of exactly 0xFFFFFFFF is indistinguishable from the sentinel, so it needs ZIP64
|
||||
// just like a larger one; the other local size must then become a sentinel too. Both orders.
|
||||
assert_local_pair_and_central_selectivity(100, u32::MAX as u64);
|
||||
assert_local_pair_and_central_selectivity(u32::MAX as u64, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_zip64_extra_carries_both_sizes() {
|
||||
// When only one size overflows the u32 limit, the local header still carries both sizes —
|
||||
// original then compressed — because a local ZIP64 extra that carries either must carry
|
||||
// both (APPNOTE 4.5.3). Either size may be the one past the limit.
|
||||
for (compressed, uncompressed) in [(100u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, 100)] {
|
||||
let (local, _) = entry_headers("a", 0, 0, compressed, uncompressed, 0);
|
||||
let extra = local_extra(&local);
|
||||
assert_eq!(u16::from_le_bytes(extra[..2].try_into().unwrap_or([0; 2])), 0x0001, "ZIP64 extra id");
|
||||
assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "both sizes are present");
|
||||
assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first");
|
||||
assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip64_entries_need_version_4_5() {
|
||||
// An entry that carries ZIP64 fields must advertise version 4.5 to extract, in both the
|
||||
// local file header and the central directory entry (APPNOTE 4.4.3). An entry that fits
|
||||
// every classic field stays at 2.0, so small archives are unchanged.
|
||||
let (local, central) = entry_headers("a", 0, 0, u32::MAX as u64 + 1, u32::MAX as u64 + 1, 0);
|
||||
assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 45, "local version needed");
|
||||
assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 45, "central version needed");
|
||||
let (local, central) = entry_headers("a", 0, 0, 4, 8, 0);
|
||||
assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 20, "local version needed");
|
||||
assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 20, "central version needed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_equal_to_the_sentinel_is_offloaded() {
|
||||
// A real size of exactly u32::MAX is indistinguishable from the 0xFFFFFFFF sentinel, so it
|
||||
// too must be offloaded: every classic field set to the sentinel needs its 8-byte value in
|
||||
// the central extra, or a reader such as Python's zipfile reports "Compress size not
|
||||
// found". Both orders of which size is the MAX+1 one must work.
|
||||
for (compressed, uncompressed) in [(u32::MAX as u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, u32::MAX as u64)] {
|
||||
let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0);
|
||||
assert_eq!(u32::from_le_bytes(local[18..22].try_into().unwrap_or([0; 4])), u32::MAX, "local compressed sentinel");
|
||||
assert_eq!(u32::from_le_bytes(local[22..26].try_into().unwrap_or([0; 4])), u32::MAX, "local original sentinel");
|
||||
assert_eq!(u32::from_le_bytes(central[20..24].try_into().unwrap_or([0; 4])), u32::MAX, "central compressed sentinel");
|
||||
assert_eq!(u32::from_le_bytes(central[24..28].try_into().unwrap_or([0; 4])), u32::MAX, "central original sentinel");
|
||||
let extra = central_extra(¢ral);
|
||||
assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "a value for each sentinel field");
|
||||
assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first");
|
||||
assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,13 @@ use super::{Dict, Document, FieldConflicts, HashMap, HashSet, ObjRef, Object, Or
|
||||
/// go to the copied pages.
|
||||
pub fn extract_pages(src: &Document, pages: &[usize]) -> Result<Document, OrganizeError> {
|
||||
let mut out = Document::new_empty();
|
||||
let mut labels = Vec::new();
|
||||
let mut has_labels = false;
|
||||
crate::labels::collect_page_labels(src, pages, 0, &mut labels, &mut has_labels)?;
|
||||
// One source: nothing to deduplicate. Its fields keep their names (a source with two
|
||||
// same-named field roots stays one field, as it was), so Extract and Split never rename.
|
||||
let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?;
|
||||
crate::labels::finish_page_labels(&mut out, &labels, has_labels)?;
|
||||
// The bookmarks of these pages come along, mapped to the copies (#4020).
|
||||
let outline = kept_bookmarks(source_outline(src, imported.page_map));
|
||||
if !outline.is_empty() {
|
||||
@@ -119,6 +123,8 @@ pub struct Combiner {
|
||||
out: Document,
|
||||
marks: Vec<Mark>,
|
||||
attachments: Vec<(Vec<u8>, Object)>,
|
||||
labels: Vec<crate::LabelRange>,
|
||||
has_labels: bool,
|
||||
/// The first source's print standard, and what its pages brought along.
|
||||
standard: Option<Standard>,
|
||||
/// Whether every source so far declares the first source's print standard.
|
||||
@@ -156,6 +162,8 @@ impl Combiner {
|
||||
out: Document::new_empty(),
|
||||
marks: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
has_labels: false,
|
||||
standard: None,
|
||||
agreed: true,
|
||||
field_conflicts,
|
||||
@@ -182,7 +190,12 @@ impl Combiner {
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let at = crate::page_count(&self.out)?;
|
||||
let mut labels = self.labels.clone();
|
||||
let mut has_labels = self.has_labels;
|
||||
crate::labels::collect_page_labels(src, &pages, at, &mut labels, &mut has_labels)?;
|
||||
let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?;
|
||||
self.labels = labels;
|
||||
self.has_labels = has_labels;
|
||||
self.renamed_fields.extend(imported.renamed_fields);
|
||||
if let Some(first) = imported.pages.first() {
|
||||
let outline = source_outline(src, imported.page_map);
|
||||
@@ -212,13 +225,14 @@ impl Combiner {
|
||||
|
||||
/// [`Combiner::finish`] reporting what the combine renamed.
|
||||
pub fn finish_with_renames(self) -> Result<CombineOutcome, OrganizeError> {
|
||||
let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self;
|
||||
let Combiner { mut out, marks, attachments, labels, has_labels, standard, agreed, renamed_fields, .. } = self;
|
||||
if let Some(first) = standard.filter(|_| agreed) {
|
||||
crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?;
|
||||
}
|
||||
// Sources often share fonts, images and profiles (or are the same file): store them once.
|
||||
let all: Vec<ObjRef> = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect();
|
||||
crate::dedupe::dedupe_resources(&mut out, &all, false);
|
||||
crate::labels::finish_page_labels(&mut out, &labels, has_labels)?;
|
||||
add_outline(&mut out, &marks)?;
|
||||
set_attachments(&mut out, attachments)?;
|
||||
Ok(CombineOutcome { doc: out, renamed_fields })
|
||||
@@ -299,9 +313,18 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu
|
||||
// The pages in the runs' order. A file's first run comes before its others, and its pages
|
||||
// were copied in run order, so its bookmark (its first copied page) is its first page shown.
|
||||
let mut order: Vec<(ObjRef, Dict)> = Vec::new();
|
||||
let mut labels = Vec::new();
|
||||
let mut has_labels = false;
|
||||
for (file, start, len) in slices {
|
||||
let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default();
|
||||
let end = start.saturating_add(len);
|
||||
let at = order.len();
|
||||
let run = copies.get(file).and_then(|c| c.get(start..end)).unwrap_or_default();
|
||||
order.extend(run.iter().map(|r| (*r, Dict::new())));
|
||||
if let Some((_, f)) = files.get(file)
|
||||
&& let Some(pages) = f.pages.get(start..end)
|
||||
{
|
||||
crate::labels::collect_page_labels(f.src, pages, at, &mut labels, &mut has_labels)?;
|
||||
}
|
||||
}
|
||||
rebuild(&mut out, &order)?;
|
||||
if let Some((declared, src, objects)) = standard.filter(|_| agreed) {
|
||||
@@ -309,6 +332,7 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu
|
||||
}
|
||||
let all: Vec<ObjRef> = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect();
|
||||
crate::dedupe::dedupe_resources(&mut out, &all, false);
|
||||
crate::labels::finish_page_labels(&mut out, &labels, has_labels)?;
|
||||
add_outline(&mut out, &marks)?;
|
||||
set_attachments(&mut out, attachments)?;
|
||||
Ok(CombineOutcome { doc: out, renamed_fields })
|
||||
|
||||
@@ -360,11 +360,13 @@ pub fn set_page_label_ranges(doc: &mut Document, ranges: &[LabelRange]) -> Resul
|
||||
return Err(invalid_label("the number tree has too many entries"));
|
||||
}
|
||||
// Validate every range and all effective labels before changing any document object.
|
||||
let mut prefix_left = MAX_PREFIX_TOTAL_BYTES;
|
||||
for r in ranges {
|
||||
if r.first == 0 || i64::try_from(r.start).is_err() {
|
||||
return Err(invalid_label("range starts and first numbers must be valid positive PDF integers"));
|
||||
}
|
||||
r.label(r.start)?;
|
||||
prefix_left = prefix_left.checked_sub(r.prefix.len()).ok_or_else(|| invalid_label("decoded prefixes exceed 4 MiB in total"))?;
|
||||
}
|
||||
let mut sorted = ranges.to_vec();
|
||||
sorted.sort_by_key(|r| r.start);
|
||||
@@ -450,15 +452,9 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl
|
||||
}
|
||||
// Drop ranges that only continue the previous one.
|
||||
let mut merged: Vec<LabelRange> = Vec::new();
|
||||
let mut prefix_left = MAX_PREFIX_TOTAL_BYTES;
|
||||
for r in ranges {
|
||||
let continues = merged.last().is_some_and(|p| {
|
||||
p.style == r.style
|
||||
&& p.prefix == r.prefix
|
||||
&& r.start.checked_sub(p.start).and_then(|n| u32::try_from(n).ok()).and_then(|n| p.first.checked_add(n)) == Some(r.first)
|
||||
});
|
||||
if !continues {
|
||||
merged.push(r);
|
||||
}
|
||||
push_merged_range(&mut merged, &mut prefix_left, r)?;
|
||||
}
|
||||
// Plain 1, 2, 3 everywhere is what a document without labels shows: remove them.
|
||||
if merged.len() == 1 && merged[0] == default_range() {
|
||||
@@ -467,6 +463,117 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl
|
||||
set_page_label_ranges(doc, &merged)
|
||||
}
|
||||
|
||||
fn push_merged_range(merged: &mut Vec<LabelRange>, prefix_left: &mut usize, r: LabelRange) -> Result<(), OrganizeError> {
|
||||
let continues = merged.last().is_some_and(|p| {
|
||||
p.style == r.style
|
||||
&& p.prefix == r.prefix
|
||||
&& match r.style {
|
||||
LabelStyle::None => p.first == r.first,
|
||||
_ => r.start.checked_sub(p.start).and_then(|n| u32::try_from(n).ok()).and_then(|n| p.first.checked_add(n)) == Some(r.first),
|
||||
}
|
||||
});
|
||||
if !continues {
|
||||
if merged.len() + 1 >= MAX_LABEL_TREE_WORK {
|
||||
return Err(invalid_label("the number tree has too many entries"));
|
||||
}
|
||||
*prefix_left = prefix_left.checked_sub(r.prefix.len()).ok_or_else(|| invalid_label("decoded prefixes exceed 4 MiB in total"))?;
|
||||
merged.push(r);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decimal numbering that shows the source page's physical number (`src_page` is 0-based).
|
||||
fn source_physical_range(dst_start: usize, src_page: usize) -> LabelRange {
|
||||
LabelRange { start: dst_start, style: LabelStyle::Decimal, prefix: String::new(), first: u32::try_from(src_page.saturating_add(1)).unwrap_or(1) }
|
||||
}
|
||||
|
||||
/// Whether `set_page_label_ranges` can store `r` while it covers `r.start..end` (`end` exclusive).
|
||||
/// Entry-count and aggregate-prefix limits stay hard errors in [`push_merged_range`].
|
||||
fn range_writable(r: &LabelRange, end: usize) -> bool {
|
||||
r.label(r.start).is_ok() && check_spans(std::slice::from_ref(r), end).is_ok()
|
||||
}
|
||||
|
||||
/// Append the label ranges that `pages` (0-based indices in `src`) carry when placed at
|
||||
/// `dst_start` in an output document. Sets `has_labels` when `src` has `/PageLabels`.
|
||||
pub(crate) fn collect_page_labels(
|
||||
src: &Document,
|
||||
pages: &[usize],
|
||||
dst_start: usize,
|
||||
out: &mut Vec<LabelRange>,
|
||||
has_labels: &mut bool,
|
||||
) -> Result<(), OrganizeError> {
|
||||
if pages.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut src_ranges = checked_ranges(src).unwrap_or_default();
|
||||
if !src_ranges.is_empty() {
|
||||
*has_labels = true;
|
||||
}
|
||||
for r in &mut src_ranges {
|
||||
// An empty prefix-only range shows each page's physical number in the source.
|
||||
if r.style == LabelStyle::None && r.prefix.is_empty() {
|
||||
r.style = LabelStyle::Decimal;
|
||||
r.first = u32::try_from(r.start.saturating_add(1)).unwrap_or(1);
|
||||
}
|
||||
}
|
||||
if src_ranges.first().is_none_or(|r| r.start > 0) {
|
||||
src_ranges.insert(0, default_range());
|
||||
}
|
||||
let mut prefix_left = MAX_PREFIX_TOTAL_BYTES.saturating_sub(out.iter().map(|r| r.prefix.len()).sum::<usize>());
|
||||
let mut i = 0;
|
||||
while i < pages.len() {
|
||||
let src_start = pages[i];
|
||||
let mut len = 1;
|
||||
while i + len < pages.len() && src_start.checked_add(len) == Some(pages[i + len]) {
|
||||
len += 1;
|
||||
}
|
||||
let run_dst = dst_start + i;
|
||||
let src_end = src_start.saturating_add(len);
|
||||
let run_end = run_dst.saturating_add(len);
|
||||
let idx = src_ranges.partition_point(|r| r.start <= src_start).saturating_sub(1);
|
||||
let span_end = |following: Option<usize>| {
|
||||
following
|
||||
.filter(|start| *start > src_start && *start < src_end)
|
||||
.and_then(|start| start.checked_sub(src_start).and_then(|delta| run_dst.checked_add(delta)))
|
||||
.unwrap_or(run_end)
|
||||
};
|
||||
if let Some(first_range) = src_ranges.get(idx) {
|
||||
let mapped = match first_range.number(src_start) {
|
||||
Ok(first) => LabelRange { start: run_dst, style: first_range.style, prefix: first_range.prefix.clone(), first },
|
||||
Err(_) => source_physical_range(run_dst, src_start),
|
||||
};
|
||||
let mapped = if range_writable(&mapped, span_end(src_ranges.get(idx + 1).map(|r| r.start))) {
|
||||
mapped
|
||||
} else {
|
||||
source_physical_range(run_dst, src_start)
|
||||
};
|
||||
push_merged_range(out, &mut prefix_left, mapped)?;
|
||||
}
|
||||
let tail = src_ranges.get(idx + 1..).unwrap_or_default();
|
||||
for (n, r) in tail.iter().enumerate() {
|
||||
if r.start >= src_end {
|
||||
break;
|
||||
}
|
||||
let Some(delta) = r.start.checked_sub(src_start) else { continue };
|
||||
let Some(start) = run_dst.checked_add(delta) else { continue };
|
||||
let inner = LabelRange { start, style: r.style, prefix: r.prefix.clone(), first: r.first };
|
||||
let following = tail.get(n + 1).map(|next| next.start);
|
||||
let inner = if range_writable(&inner, span_end(following)) { inner } else { source_physical_range(start, r.start) };
|
||||
push_merged_range(out, &mut prefix_left, inner)?;
|
||||
}
|
||||
i += len;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write collected page label ranges to `dst` when at least one source carried `/PageLabels`.
|
||||
pub(crate) fn finish_page_labels(dst: &mut Document, ranges: &[LabelRange], has_labels: bool) -> Result<(), OrganizeError> {
|
||||
if has_labels && !ranges.is_empty() {
|
||||
set_page_label_ranges(dst, ranges)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -2223,6 +2223,176 @@ fn deleting_pages_keeps_the_label_ranges_on_their_pages() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracting_splitting_and_combining_preserve_page_labels() {
|
||||
use crate::{
|
||||
LabelRange,
|
||||
LabelStyle::{Decimal, LowerRoman, UpperAlpha},
|
||||
SplitBy,
|
||||
};
|
||||
let ten = || {
|
||||
let mut d = doc_a();
|
||||
for at in 3..10 {
|
||||
crate::insert_blank_page(&mut d, at, 300.0, 400.0).unwrap();
|
||||
}
|
||||
let ranges = [
|
||||
LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 3, style: Decimal, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 7, style: Decimal, prefix: "A-".into(), first: 1 },
|
||||
];
|
||||
crate::set_page_label_ranges(&mut d, &ranges).unwrap();
|
||||
d
|
||||
};
|
||||
let d = ten();
|
||||
|
||||
// Extracting non-contiguous pages (e.g. first front-matter page and first appendix page)
|
||||
// keeps their labels and writes /PageLabels (#2670).
|
||||
let pair = full_roundtrip(&extract_pages(&d, &[0, 7]).unwrap());
|
||||
assert_eq!(crate::page_labels(&pair).unwrap(), ["i", "A-1"]);
|
||||
assert_eq!(
|
||||
crate::page_label_ranges(&pair),
|
||||
[
|
||||
LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 1, style: Decimal, prefix: "A-".into(), first: 1 },
|
||||
]
|
||||
);
|
||||
|
||||
// Contiguous runs and out-of-order picks keep each page's label and merge continuations.
|
||||
let picked = full_roundtrip(&extract_pages(&d, &[1, 2, 8, 9, 0]).unwrap());
|
||||
assert_eq!(crate::page_labels(&picked).unwrap(), ["ii", "iii", "A-2", "A-3", "i"]);
|
||||
assert_eq!(
|
||||
crate::page_label_ranges(&picked),
|
||||
[
|
||||
LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 2 },
|
||||
LabelRange { start: 2, style: Decimal, prefix: "A-".into(), first: 2 },
|
||||
LabelRange { start: 4, style: LowerRoman, prefix: String::new(), first: 1 },
|
||||
]
|
||||
);
|
||||
|
||||
// An unlabelled source stays unlabelled after extraction.
|
||||
let unlabelled = full_roundtrip(&extract_pages(&doc_a(), &[2, 0]).unwrap());
|
||||
assert!(crate::page_label_ranges(&unlabelled).is_empty());
|
||||
|
||||
// Splitting preserves each part's labels, including mid-range starting numbers.
|
||||
let parts = crate::split(&d, &SplitBy::PageCount(4)).unwrap();
|
||||
let part_labels: Vec<Vec<String>> = parts.iter().map(|p| crate::page_labels(&full_roundtrip(p)).unwrap()).collect();
|
||||
assert_eq!(part_labels, vec![vec!["i", "ii", "iii", "1"], vec!["2", "3", "4", "A-1"], vec!["A-2", "A-3"],]);
|
||||
|
||||
// Combining whole files, selected pages, and grouped runs preserves labels from each source.
|
||||
let mut b = doc_b();
|
||||
crate::set_page_label_ranges(&mut b, &[LabelRange { start: 0, style: UpperAlpha, prefix: "B-".into(), first: 1 }]).unwrap();
|
||||
let combined = full_roundtrip(&combine_selected(&[("D", &d, Some(&[0, 1, 7])), ("Plain", &doc_a(), Some(&[0, 1])), ("B", &b, None)]).unwrap());
|
||||
assert_eq!(crate::page_labels(&combined).unwrap(), ["i", "ii", "A-1", "1", "2", "B-A", "B-B"]);
|
||||
|
||||
let grouped =
|
||||
full_roundtrip(&crate::combine_grouped(&[(0, "D", &d, Some(&[0, 1])), (1, "B", &b, Some(&[1])), (0, "D", &d, Some(&[2, 7]))]).unwrap());
|
||||
assert_eq!(crate::page_labels(&grouped).unwrap(), ["i", "ii", "B-B", "iii", "A-1"]);
|
||||
|
||||
// Ranges starting after page 0 and prefix-only ranges (LabelStyle::None) merge and carry over.
|
||||
let mut late = doc_a();
|
||||
crate::set_page_label_ranges(
|
||||
&mut late,
|
||||
&[
|
||||
LabelRange { start: 1, style: crate::LabelStyle::None, prefix: "Cover".into(), first: 1 },
|
||||
LabelRange { start: 2, style: LowerRoman, prefix: String::new(), first: 4 },
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let from_late = full_roundtrip(&extract_pages(&late, &[0, 1, 1, 2]).unwrap());
|
||||
assert_eq!(crate::page_labels(&from_late).unwrap(), ["1", "Cover", "Cover", "iv"]);
|
||||
assert_eq!(
|
||||
crate::page_label_ranges(&from_late),
|
||||
[
|
||||
LabelRange { start: 0, style: Decimal, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 1, style: crate::LabelStyle::None, prefix: "Cover".into(), first: 1 },
|
||||
LabelRange { start: 3, style: LowerRoman, prefix: String::new(), first: 4 },
|
||||
]
|
||||
);
|
||||
|
||||
// An empty selection from a labelled source in combine_selected does not add /PageLabels.
|
||||
let empty_pick = full_roundtrip(&combine_selected(&[("D", &d, Some(&[])), ("Plain", &doc_a(), Some(&[0, 1]))]).unwrap());
|
||||
assert!(crate::page_label_ranges(&empty_pick).is_empty());
|
||||
|
||||
// A damaged (cyclic) /PageLabels tree does not prevent extracting pages.
|
||||
let mut cyclic = doc_a();
|
||||
let tree = cyclic.add(Object::Null);
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)]));
|
||||
cyclic.set(tree, Object::Dict(dict));
|
||||
cyclic.update_dict(cyclic.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
|
||||
let extracted_cyclic = full_roundtrip(&extract_pages(&cyclic, &[0, 2]).unwrap());
|
||||
assert_eq!(crate::page_labels(&extracted_cyclic).unwrap(), ["1", "2"]);
|
||||
|
||||
// An empty prefix-only range falls back to physical page numbers in the source; extracting a
|
||||
// later page covered by it preserves that physical page number across save/reopen.
|
||||
let mut empty_prefix = doc_a();
|
||||
crate::set_page_label_ranges(
|
||||
&mut empty_prefix,
|
||||
&[
|
||||
LabelRange { start: 0, style: crate::LabelStyle::None, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 2, style: LowerRoman, prefix: String::new(), first: 1 },
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(crate::PageLabels::new(&empty_prefix).unwrap().label(1).unwrap(), "2");
|
||||
let extracted_empty_prefix = full_roundtrip(&extract_pages(&empty_prefix, &[1]).unwrap());
|
||||
assert_eq!(crate::PageLabels::new(&extracted_empty_prefix).unwrap().label(0).unwrap(), "2");
|
||||
|
||||
// Repeated non-continuing ranges that exceed the 4 MiB total-prefix limit or the range-count
|
||||
// limit are refused rather than writing an unreadable tree or silently dropping labels.
|
||||
let mut one = flat_doc(1);
|
||||
crate::number_pages(&mut one, 0, 0, Decimal, &"x".repeat(1_000), 1).unwrap();
|
||||
let err = extract_pages(&one, &vec![0; 4_195]).unwrap_err().to_string();
|
||||
assert!(err.contains("decoded prefixes exceed 4 MiB in total"), "{err}");
|
||||
|
||||
crate::number_pages(&mut one, 0, 0, LowerRoman, "", 2).unwrap();
|
||||
let err = extract_pages(&one, &vec![0; pdfcraft_cos::page_labels::MAX_LABEL_TREE_WORK]).unwrap_err().to_string();
|
||||
assert!(err.contains("the number tree has too many entries"), "{err}");
|
||||
}
|
||||
|
||||
/// A range the reader accepts but the writer cannot store (a Roman numeral past 1,024 bytes, or a
|
||||
/// decimal span whose labels grow past that) degrades to the source physical numbers. Extract
|
||||
/// still succeeds, and a neighbouring range that does fit is kept.
|
||||
#[test]
|
||||
fn extracting_unrepresentable_labels_uses_physical_numbers() {
|
||||
let mut roman = doc_a();
|
||||
let mut spec = Dict::new();
|
||||
spec.set(b"S".to_vec(), Object::name("R"));
|
||||
spec.set(b"St".to_vec(), Object::Int(4_000_000));
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)]));
|
||||
let tree = roman.add(Object::Dict(tree));
|
||||
roman.update_dict(roman.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
|
||||
let extracted = full_roundtrip(&extract_pages(&roman, &[0, 2]).unwrap());
|
||||
assert_eq!(crate::page_labels(&extracted).unwrap(), ["1", "3"]);
|
||||
|
||||
let mut wide = flat_doc(5);
|
||||
let mut spec = Dict::new();
|
||||
spec.set(b"P".to_vec(), Object::String(PdfString::text(&"p".repeat(1_020))));
|
||||
spec.set(b"S".to_vec(), Object::name("D"));
|
||||
spec.set(b"St".to_vec(), Object::Int(9_996));
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)]));
|
||||
let tree = wide.add(Object::Dict(tree));
|
||||
wide.update_dict(wide.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
|
||||
let extracted = full_roundtrip(&extract_pages(&wide, &[0, 1, 2, 3, 4]).unwrap());
|
||||
assert_eq!(crate::page_labels(&extracted).unwrap(), ["1", "2", "3", "4", "5"]);
|
||||
|
||||
let mut mixed = doc_a();
|
||||
let mut ok = Dict::new();
|
||||
ok.set(b"P".to_vec(), Object::String(PdfString::text("ok-")));
|
||||
ok.set(b"S".to_vec(), Object::name("D"));
|
||||
let mut huge = Dict::new();
|
||||
huge.set(b"S".to_vec(), Object::name("R"));
|
||||
huge.set(b"St".to_vec(), Object::Int(4_000_000));
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(ok), Object::Int(1), Object::Dict(huge)]));
|
||||
let tree = mixed.add(Object::Dict(tree));
|
||||
mixed.update_dict(mixed.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
|
||||
let extracted = full_roundtrip(&extract_pages(&mixed, &[0, 1]).unwrap());
|
||||
assert_eq!(crate::page_labels(&extracted).unwrap(), ["ok-1", "2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_label_limits_reject_unsupported_edits_before_mutating() {
|
||||
use crate::LabelStyle::*;
|
||||
|
||||
@@ -219,14 +219,22 @@ pub(crate) fn page_form(
|
||||
body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes());
|
||||
xobjects.set(name.into_bytes(), Object::Ref(ap));
|
||||
}
|
||||
if !xobjects.is_empty() {
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
}
|
||||
// With no appearance added the form keeps the page's own Resources, shared by reference,
|
||||
// rather than a deep copy per printed page.
|
||||
let resources = match page.dict.get(b"Resources") {
|
||||
Some(r) if k == 0 && doc.resolve(r).as_dict().is_some() => r.clone(),
|
||||
_ => {
|
||||
if !xobjects.is_empty() {
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
}
|
||||
Object::Dict(res)
|
||||
}
|
||||
};
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect()));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
d.set(b"Resources".to_vec(), resources);
|
||||
if let Some(g) = page.dict.get(b"Group") {
|
||||
d.set(b"Group".to_vec(), g.clone());
|
||||
}
|
||||
|
||||
@@ -1285,3 +1285,15 @@ fn contents_pieces_join_with_a_space_across_a_trailing_comment() {
|
||||
let printed_ctrl = impose(&with_newline, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap();
|
||||
assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_form_without_annotations_shares_the_pages_indirect_resources() {
|
||||
let mut doc = fixture(2);
|
||||
let pages = pdfcraft_model::pages(&doc);
|
||||
let res = doc.add(Object::Dict([(b"Font".to_vec(), Object::Dict(Dict::new()))].into_iter().collect()));
|
||||
doc.update_dict(pages[1].obj, |d| d.set(b"Resources".to_vec(), Object::Ref(res))).unwrap();
|
||||
let pages = pdfcraft_model::pages(&doc);
|
||||
let form = imposition::page_form(&mut doc, &pages, 1, Content::default(), &mut Default::default()).unwrap();
|
||||
let Object::Stream(s) = &*doc.get(form) else { panic!("form stream") };
|
||||
assert_eq!(s.dict.get(b"Resources"), Some(&Object::Ref(res)));
|
||||
}
|
||||
@@ -34,6 +34,9 @@ unicode-bidi = "0.3"
|
||||
getrandom = { version = "0.4", features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# The content crate's parser, compared operator by operator against hayro's in
|
||||
# `tests/content_differential.rs` (L3 may depend on L2).
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
# Builds the decompression-bomb streams in the raster tests.
|
||||
flate2 = { workspace = true }
|
||||
@@ -169,7 +169,8 @@ impl<'a> Inspector<'a> {
|
||||
if let Some(tree) =
|
||||
doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o))
|
||||
{
|
||||
me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named);
|
||||
let mut key_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES;
|
||||
me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named, &mut key_budget);
|
||||
}
|
||||
me.named = named;
|
||||
me
|
||||
|
||||
@@ -79,6 +79,7 @@ impl<'a> Inspector<'a> {
|
||||
seen: &mut HashSet<*const Dictionary>,
|
||||
depth: u32,
|
||||
out: &mut HashMap<Vec<u8>, &'a Object>,
|
||||
key_budget: &mut usize,
|
||||
) {
|
||||
if depth > 32 || !seen.insert(node as *const _) {
|
||||
return;
|
||||
@@ -87,18 +88,25 @@ impl<'a> Inspector<'a> {
|
||||
for pair in pairs.chunks(2) {
|
||||
if let [k, v] = pair {
|
||||
let key = match self.resolve(k) {
|
||||
Object::String(s, _) => s.clone(),
|
||||
Object::Name(n) => n.clone(),
|
||||
Object::String(s, _) => s,
|
||||
Object::Name(n) => n,
|
||||
_ => continue,
|
||||
};
|
||||
out.entry(key).or_insert(v);
|
||||
// Every key is hashed, so its bytes are spent from one allowance for the whole
|
||||
// tree (a key repeated by reference is charged each time); one past it is
|
||||
// not indexed. Only a first occurrence is copied.
|
||||
let Some(left) = key_budget.checked_sub(key.len()) else { continue };
|
||||
*key_budget = left;
|
||||
if !out.contains_key(key.as_slice()) {
|
||||
out.insert(key.clone(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) {
|
||||
for k in kids {
|
||||
if let Some(d) = self.dict(k) {
|
||||
self.name_tree_raw(d, seen, depth + 1, out);
|
||||
self.name_tree_raw(d, seen, depth + 1, out, key_budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -559,4 +567,17 @@ mod open_dest_tests {
|
||||
let shallow = ["<< /Kids [7 0 R] >>".to_string(), "<< /Kids [8 0 R] >>".into(), "<< /Names [(x) [4 0 R /Fit]] >>".into()];
|
||||
assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &shallow), Some((1, DestView::Fit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_repeated_by_reference_is_charged_each_time_against_the_name_tree_allowance() {
|
||||
let big = format!("<{}>", "41".repeat(1 << 20));
|
||||
let tree = |repeats: usize| {
|
||||
let names = format!("{}(x) [4 0 R /Fit]", "7 0 R [4 0 R /Fit] ".repeat(repeats));
|
||||
[format!("<< /Names [{names}] >>"), big.clone()]
|
||||
};
|
||||
let catalog = "/OpenAction (x) /Names << /Dests 6 0 R >>";
|
||||
assert_eq!(open_dest(catalog, &tree(2)), Some((1, DestView::Fit)));
|
||||
// 17 MiB of repeats of one reference spend the 16 MiB allowance before the real key.
|
||||
assert_eq!(open_dest(catalog, &tree(17)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! The two content-stream parsers PdfCraft runs, operator by operator on the same bytes.
|
||||
//!
|
||||
//! The renderer, text extraction and Find read a page through hayro's tokenizer
|
||||
//! (`hayro_syntax::content::UntypedIter`, typed through `TypedIter`). Edit text, redaction,
|
||||
//! the redaction audit, Office export, accessibility and the automation tools read it through
|
||||
//! `pdfcraft_content::parse`. Where the two disagree on the same bytes, a feature acts on a
|
||||
//! page the renderer never showed: #3107, #3108, #3111 and #3177 are that class.
|
||||
//!
|
||||
//! Each fixture here is parsed by both and the operator sequences compared. Where the parsers
|
||||
//! agree, that is asserted. Where they are known to disagree, the test pins what *each* side
|
||||
//! does today and names the issue, so a change on either side fails here and the expectation
|
||||
//! is updated with it. Nothing in this file touches product code, and no oracle runs here: the
|
||||
//! MuPDF and pdftotext readings quoted in comments were taken by hand on one-page files built
|
||||
//! from the same bytes.
|
||||
//!
|
||||
//! Several tests pin what `vendor/hayro-syntax` does today, so a hayro upgrade or a new patch
|
||||
//! to it can fail them on purpose. When that happens, update the pinned side to the new
|
||||
//! behaviour and note the upstream change (the version or patch, and its PR) in that test's
|
||||
//! comment, as the tests under "divergences closed" do.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::content::{TypedIter, UntypedIter};
|
||||
use hayro_syntax::object::{MaybeRef, Object as HObject};
|
||||
use pdfcraft_cos::Object as CObject;
|
||||
|
||||
/// One operator with its operands, normalized so the two parsers can be compared as text.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Tok {
|
||||
op: String,
|
||||
operands: Vec<String>,
|
||||
}
|
||||
|
||||
fn tok(op: &str, operands: &[&str]) -> Tok {
|
||||
Tok { op: op.into(), operands: operands.iter().map(|s| s.to_string()).collect() }
|
||||
}
|
||||
|
||||
fn num(v: f64) -> String {
|
||||
let r = (v * 1e6).round() / 1e6;
|
||||
if r.fract() == 0.0 && r.abs() < 1e15 { format!("{}", r as i64) } else { format!("{r}") }
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().fold(String::new(), |mut s, b| {
|
||||
let _ = write!(s, "{b:02x}");
|
||||
s
|
||||
})
|
||||
}
|
||||
|
||||
fn norm_cos(o: &CObject) -> String {
|
||||
match o {
|
||||
CObject::Null => "null".into(),
|
||||
CObject::Bool(b) => b.to_string(),
|
||||
CObject::Int(i) => num(*i as f64),
|
||||
CObject::Real(r) => num(*r),
|
||||
CObject::String(s) => format!("({})", hex(&s.bytes)),
|
||||
CObject::Name(n) => format!("/{}", String::from_utf8_lossy(n)),
|
||||
CObject::Array(a) => format!("[{}]", a.iter().map(norm_cos).collect::<Vec<_>>().join(" ")),
|
||||
CObject::Dict(d) => {
|
||||
let mut entries: Vec<String> = d.iter().map(|(k, v)| format!("/{} {}", String::from_utf8_lossy(k), norm_cos(v))).collect();
|
||||
entries.sort();
|
||||
format!("<<{}>>", entries.join(" "))
|
||||
}
|
||||
CObject::Ref(r) => format!("{} {} R", r.num, r.generation),
|
||||
CObject::Stream(_) => "stream".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn norm_hayro(o: &HObject<'_>) -> String {
|
||||
match o {
|
||||
HObject::Null(_) => "null".into(),
|
||||
HObject::Boolean(b) => b.to_string(),
|
||||
HObject::Number(n) => num(n.as_f64()),
|
||||
HObject::String(s) => format!("({})", hex(s.as_bytes())),
|
||||
HObject::Name(n) => format!("/{}", String::from_utf8_lossy(n.as_ref())),
|
||||
HObject::Array(a) => format!("[{}]", a.raw_iter().map(|m| norm_maybe(&m)).collect::<Vec<_>>().join(" ")),
|
||||
HObject::Dict(d) => {
|
||||
let mut entries: Vec<String> = d.entries().map(|(k, v)| format!("/{} {}", String::from_utf8_lossy(k.as_ref()), norm_maybe(&v))).collect();
|
||||
entries.sort();
|
||||
format!("<<{}>>", entries.join(" "))
|
||||
}
|
||||
// An inline image: its dictionary and data. ISO 32000-2 §8.9.7 (p.268) defines the data as
|
||||
// the bytes between `ID` and `EI` "excluding the white-space delimiting those operators",
|
||||
// which is what the content crate keeps. hayro keeps exactly the samples of an unfiltered
|
||||
// image whose length its parameters give (#3438), but a filtered image, which it ends at
|
||||
// a candidate `EI`, still keeps the white-space byte before that `EI` as its last byte.
|
||||
// `hayro_keeps_the_delimiter_before_ei_only_in_filtered_image_data` pins both. The
|
||||
// comparisons strip that byte from filtered images only, so that every other difference
|
||||
// stands out: stripping it from an unfiltered image would cut a sample that happens to
|
||||
// be white space.
|
||||
HObject::Stream(s) => {
|
||||
let raw = s.raw_data();
|
||||
let filtered = s.dict().contains_key(b"F") || s.dict().contains_key(b"Filter");
|
||||
let data = match raw.split_last() {
|
||||
Some((b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ', rest)) if filtered => rest,
|
||||
_ => &raw[..],
|
||||
};
|
||||
format!("{} data:{}", norm_hayro(&HObject::Dict(s.dict().clone())), hex(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn norm_maybe(m: &MaybeRef<HObject<'_>>) -> String {
|
||||
match m {
|
||||
MaybeRef::NotRef(o) => norm_hayro(o),
|
||||
MaybeRef::Ref(r) => format!("{} {} R", r.obj_number, r.gen_number),
|
||||
}
|
||||
}
|
||||
|
||||
/// The content crate's operators.
|
||||
fn content(data: &[u8]) -> Vec<Tok> {
|
||||
pdfcraft_content::parse(data)
|
||||
.ops
|
||||
.iter()
|
||||
.map(|op| Tok {
|
||||
op: String::from_utf8_lossy(&op.op).into_owned(),
|
||||
operands: match &op.inline {
|
||||
Some((dict, image)) => vec![format!("{} data:{}", norm_cos(&CObject::Dict(dict.clone())), hex(image))],
|
||||
None => op.operands.iter().map(norm_cos).collect(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// hayro's untyped instructions: every operator the tokenizer reads, with its operand stack.
|
||||
fn hayro(data: &[u8]) -> Vec<Tok> {
|
||||
let mut iter = UntypedIter::new(data);
|
||||
let mut out = Vec::new();
|
||||
while let Some(instr) = iter.next() {
|
||||
out.push(Tok { op: String::from_utf8_lossy(instr.operator).into_owned(), operands: instr.operands().map(norm_hayro).collect() });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How many instructions hayro's typed iterator, the layer the renderer consumes, yields before
|
||||
/// it stops.
|
||||
fn hayro_typed_len(data: &[u8]) -> usize {
|
||||
let mut iter = TypedIter::new(data);
|
||||
let mut n = 0;
|
||||
while iter.next().is_some() {
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn ops(toks: &[Tok]) -> Vec<&str> {
|
||||
toks.iter().map(|t| t.op.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Both parsers on `data`, asserting they agree and that the operators are `expected`.
|
||||
fn agree(name: &str, data: &[u8], expected: &[&str]) {
|
||||
let (c, h) = (content(data), hayro(data));
|
||||
assert_eq!(c, h, "{name}: content crate (left) and hayro (right) differ on {:?}", String::from_utf8_lossy(data));
|
||||
assert_eq!(ops(&c), expected, "{name}");
|
||||
}
|
||||
|
||||
/// An unfiltered 4 × 1 grey inline image whose samples are the four bytes of `samples`.
|
||||
fn inline_gray(samples: &[u8; 4], after: &[u8]) -> Vec<u8> {
|
||||
let mut v = b"q BI /W 4 /H 1 /BPC 8 /CS /G ID ".to_vec();
|
||||
v.extend_from_slice(samples);
|
||||
v.extend_from_slice(b"\nEI Q ");
|
||||
v.extend_from_slice(after);
|
||||
v
|
||||
}
|
||||
|
||||
const GRAY_4X1: &str = "<</BPC 8 /CS /G /H 1 /W 4>>";
|
||||
const GRAY_1X1: &str = "<</BPC 8 /CS /G /H 1 /W 1>>";
|
||||
|
||||
// ── where the two parsers agree ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn the_two_parsers_agree_on_well_formed_streams_and_common_damage() {
|
||||
agree("plain", b"q 1 0 0 1 10 20 cm BT /F1 12 Tf (Hi) Tj ET Q", &["q", "cm", "BT", "Tf", "Tj", "ET", "Q"]);
|
||||
agree("number forms", b"0.5 .5 6. -.5 +3 -0 5 w", &["w"]);
|
||||
assert_eq!(content(b"0.5 .5 6. -.5 +3 -0 5 w")[0].operands, ["0.5", "0.5", "6", "-0.5", "3", "0", "5"]);
|
||||
agree("strings", b"BT (a(b)c) Tj (es\\)c\\\\ape) Tj (line\\\ncont) Tj <48 65 6c> Tj ET", &["BT", "Tj", "Tj", "Tj", "Tj", "ET"]);
|
||||
assert_eq!(content(b"(line\\\ncont) Tj")[0].operands, [format!("({})", hex(b"linecont"))], "a backslash joins the lines");
|
||||
agree("unterminated string ends both streams", b"BT (open Tj ET q Q", &["BT"]);
|
||||
agree("names with escapes", b"/A#20B gs /#41 gs", &["gs", "gs"]);
|
||||
assert_eq!(content(b"/A#20B gs /#41 gs").iter().map(|t| t.operands[0].as_str()).collect::<Vec<_>>(), ["/A B", "/A"]);
|
||||
agree("array and dictionary operands", b"/Span <</MCID 1 /ActualText (x)>> BDC [(a) -250 (b) 1.5] TJ EMC", &["BDC", "TJ", "EMC"]);
|
||||
agree("ten operands", b"1 2 3 4 5 6 7 8 9 10 d0 q Q", &["d0", "q", "Q"]);
|
||||
agree("unknown operators", b"q foo 1 bar Q BT ET", &["q", "foo", "bar", "Q", "BT", "ET"]);
|
||||
agree("comments everywhere", b"q % c1\n1 0 % c2\n0 1 0 0 cm [1 % c3\n 2] TJ /N % c4\ngs Q % end", &["q", "cm", "TJ", "gs", "Q"]);
|
||||
agree("comment without end of line", b"q Q % trailing", &["q", "Q"]);
|
||||
agree("unbalanced q/Q and BT/ET", b"Q Q q BT BT ET q", &["Q", "Q", "q", "BT", "BT", "ET", "q"]);
|
||||
agree("operands without an operator at the end", b"q 1 0 0 1 5", &["q"]);
|
||||
agree("empty", b"", &[]);
|
||||
agree("only a comment", b"% nothing", &[]);
|
||||
}
|
||||
|
||||
/// The content crate exposes two operand conventions: `Op::nums::<N>` takes the last N, as
|
||||
/// hayro's typed operators do (`0 20 Tc` is `Tc 20` to the renderer), while `Op::num(i)` counts
|
||||
/// from the front. Both tokenizers keep every operand, so the choice is each consumer's: the
|
||||
/// edit reader reads `Tc` through `num(0)` and gets 0 where the renderer gets 20, so the editor
|
||||
/// and the renderer disagree on the character spacing (MuPDF renders the 20). No issue is filed
|
||||
/// for the convention.
|
||||
#[test]
|
||||
fn surplus_operands_are_read_from_the_end_by_hayro_and_either_way_by_the_content_crate() {
|
||||
let data = b"BT 0 20 Tc (x) Tj ET";
|
||||
agree("surplus operand before Tc", data, &["BT", "Tc", "Tj", "ET"]);
|
||||
let parsed = pdfcraft_content::parse(data);
|
||||
let tc = &parsed.ops[1];
|
||||
assert_eq!(tc.nums::<1>(), Some([20.0]), "last N, as the renderer");
|
||||
assert_eq!(tc.num(0), Some(0.0), "the i-th from the front");
|
||||
let mut iter = TypedIter::new(data);
|
||||
let mut typed_tc = None;
|
||||
while let Some(instr) = iter.next() {
|
||||
if let TypedInstruction::CharacterSpacing(c) = instr {
|
||||
typed_tc = Some(c.0.as_f64());
|
||||
}
|
||||
}
|
||||
assert_eq!(typed_tc, Some(20.0), "hayro's typed Tc takes the last operand");
|
||||
}
|
||||
|
||||
// ── divergences closed since this file was written: both parsers now agree ─────────────────
|
||||
|
||||
/// hayro's typed layer used to end the whole stream at a path operator whose operands don't
|
||||
/// convert, so the renderer and text extraction showed nothing after it.
|
||||
/// Closed by #3636 (30ed249a2): `TypedIter::next` (`vendor/hayro-syntax/src/content/mod.rs`)
|
||||
/// now yields such an operator as `Fallback`, which adds nothing to the path, and reads on, as
|
||||
/// the content crate, MuPDF and pdftotext do (`After` is shown).
|
||||
#[test]
|
||||
fn both_parsers_read_on_past_a_path_operator_with_too_few_operands() {
|
||||
for (data, expected) in
|
||||
[(&b"0 0 100 re f BT /F1 12 Tf (After) Tj ET"[..], &["re", "f", "BT", "Tf", "Tj", "ET"][..]), (b"5 m 0 0 100 100 re f", &["m", "re", "f"])]
|
||||
{
|
||||
agree("untyped: both keep the short operator", data, expected);
|
||||
assert_eq!(hayro_typed_len(data), expected.len(), "hayro's typed stream reads on past the short operator");
|
||||
}
|
||||
assert_eq!(hayro_typed_len(b"0 0 100 100 re f BT /F1 12 Tf (After) Tj ET"), 6, "control: a complete re is read through");
|
||||
}
|
||||
|
||||
/// hayro's operand stack used to hold ten entries, and an eleventh ended its stream. Closed by
|
||||
/// #4345 (hayro-syntax patch 28, #4260): an instruction keeps up to 256 operands and past that
|
||||
/// drops the oldest, so both parsers read all eleven and go on, as MuPDF and pdftotext do
|
||||
/// (`AFTER` is shown).
|
||||
#[test]
|
||||
fn both_parsers_keep_more_than_ten_operands() {
|
||||
let data = b"1 2 3 4 5 6 7 8 9 10 11 d0 q Q";
|
||||
agree("eleven operands", data, &["d0", "q", "Q"]);
|
||||
assert_eq!(content(data)[0].operands.len(), 11);
|
||||
}
|
||||
|
||||
/// A stray closing delimiter used to end hayro's stream while the content crate skipped it.
|
||||
/// Closed by #3636 (30ed249a2): hayro steps over the malformed token too. MuPDF reports a
|
||||
/// syntax error and goes on (`AFTER` is shown).
|
||||
#[test]
|
||||
fn both_parsers_skip_a_stray_closing_delimiter() {
|
||||
agree("stray delimiters", b"q ) > ] } { Q", &["q", "Q"]);
|
||||
}
|
||||
|
||||
/// A name whose `#` escape is not followed by two hex digits (ISO 32000-2 §7.3.5, p.28) used to
|
||||
/// end hayro's stream. Since #3636 (30ed249a2) hayro steps over the name and reads on, so both
|
||||
/// parsers now read the same operators. One difference is still pinned: the content crate keeps
|
||||
/// the name as written as the `gs` operand, while hayro drops it, so `gs` reaches the renderer
|
||||
/// with no operand and does nothing, where the editor reads `/C#2`. No issue is filed. An empty
|
||||
/// name (`/`) is read by both. MuPDF goes on in both cases, reporting the resource as missing.
|
||||
#[test]
|
||||
fn both_parsers_read_on_past_a_malformed_name_escape_but_only_the_content_crate_keeps_the_name() {
|
||||
let data = b"/C#2 gs q Q";
|
||||
assert_eq!(content(data), [tok("gs", &["/C#2"]), tok("q", &[]), tok("Q", &[])]);
|
||||
assert_eq!(hayro(data), [tok("gs", &[]), tok("q", &[]), tok("Q", &[])], "hayro: the operators, without the name");
|
||||
agree("an empty name", b"/ gs q Q", &["gs", "q", "Q"]);
|
||||
}
|
||||
|
||||
/// #3111 (closed by #3299) made the cos lexer, and so the content crate, read `--280` as -280.
|
||||
/// hayro used to read it as the two numbers 0 and -280, so `cm` took an extra operand and the
|
||||
/// renderer drew the page with the matrix `0 0 1 0 -280 10`. Closed by #3630 (766ff9d05): a
|
||||
/// doubled sign is one number to hayro too, so both read the matrix MuPDF applies (text at
|
||||
/// x = 120). The content crate skips no byte: without the lexer's tolerance it would skip the
|
||||
/// first `-` and read the same `-280` from the rest, so the operands alone don't show it.
|
||||
#[test]
|
||||
fn both_parsers_read_a_doubled_minus_as_one_number() {
|
||||
let data = b"1 0 0 1 --280 10 cm";
|
||||
agree("doubled minus", data, &["cm"]);
|
||||
assert_eq!(content(data)[0].operands, ["1", "0", "0", "1", "-280", "10"]);
|
||||
assert_eq!(pdfcraft_content::parse(data).skipped, 0, "--280 is read as one number, not recovered from -280");
|
||||
}
|
||||
|
||||
// ── known divergences, pinned on both sides ─────────────────────────────────────────────────
|
||||
|
||||
/// No issue filed; #2877 (open) changes number reading in cos and render. ISO 32000-2 §7.3.3
|
||||
/// (p.24) says a writer "shall not use … exponential format", so neither reading is the
|
||||
/// spec's. The content crate reads `1e3` as the number 1000; hayro reads it as an unknown
|
||||
/// operator and drops the operands before it. MuPDF stops the whole stream at it
|
||||
/// ("unknown keyword: '1e3'"); pdftotext goes on.
|
||||
#[test]
|
||||
fn scientific_notation_is_a_number_to_the_content_crate_and_an_unknown_operator_to_hayro() {
|
||||
let data = b"1e3 w q Q";
|
||||
assert_eq!(content(data), [tok("w", &["1000"]), tok("q", &[]), tok("Q", &[])]);
|
||||
assert_eq!(hayro(data), [tok("w", &[]), tok("q", &[]), tok("Q", &[])]);
|
||||
// A number glued to letters: the content crate skips the digits and reads the letters as an
|
||||
// operator; hayro reads the whole token as an operator (and clears the operands before it).
|
||||
let data = b"12abc w q Q";
|
||||
assert_eq!(content(data), [tok("abc", &[]), tok("w", &[]), tok("q", &[]), tok("Q", &[])]);
|
||||
assert_eq!(hayro(data), [tok("w", &[]), tok("q", &[]), tok("Q", &[])]);
|
||||
}
|
||||
|
||||
// ── inline images ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The bytes of the first inline image each parser reads in `data`: the content crate's, then
|
||||
/// hayro's raw stream data (nothing stripped).
|
||||
fn inline_bytes(data: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>) {
|
||||
let parsed = pdfcraft_content::parse(data);
|
||||
let c = parsed.ops.iter().find_map(|op| op.inline.as_ref().map(|(_, d)| d.clone()));
|
||||
let mut iter = UntypedIter::new(data);
|
||||
let mut h = None;
|
||||
while let Some(instr) = iter.next() {
|
||||
if let Some(HObject::Stream(s)) = instr.operands().next() {
|
||||
h = Some(s.raw_data().to_vec());
|
||||
break;
|
||||
}
|
||||
}
|
||||
(c, h)
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §8.9.7 (p.268): the data lies between `ID` and `EI` "excluding the white-space
|
||||
/// delimiting those operators". The content crate keeps exactly that. hayro used to keep the
|
||||
/// delimiter before `EI` as the last byte of every inline image. Since #3438 (f9d37c089) it
|
||||
/// takes an unfiltered image's length from `/W /H /BPC /CS` and keeps exactly the samples, so
|
||||
/// both read `ABCD`. A filtered image, which hayro still ends at a candidate `EI`, keeps the
|
||||
/// delimiter: that part is still pinned. Decoders stop at the filter's end-of-data marker and
|
||||
/// ignore the extra byte, so the other tests strip it from filtered images (see `norm_hayro`).
|
||||
#[test]
|
||||
fn hayro_keeps_the_delimiter_before_ei_only_in_filtered_image_data() {
|
||||
let unfiltered = inline_bytes(&inline_gray(b"ABCD", b""));
|
||||
assert_eq!(unfiltered, (Some(b"ABCD".to_vec()), Some(b"ABCD".to_vec())), "unfiltered: both keep exactly the samples");
|
||||
let filtered = inline_bytes(b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41> EI Q");
|
||||
assert_eq!(filtered, (Some(b"41>".to_vec()), Some(b"41> ".to_vec())), "filtered: hayro keeps the space before EI");
|
||||
}
|
||||
|
||||
/// Samples that end in a white-space byte (here ` EI ` as four gray samples, the last one 0x20)
|
||||
/// are all image data when the length is computed from the parameters (4 × 1 × 8 bits = 4
|
||||
/// bytes, ISO 32000-2 §8.9.7). Both parsers keep all four bytes; hayro's raw data, unstripped,
|
||||
/// is the same four bytes, so its unfiltered-length path (#3438) does not take a white-space
|
||||
/// sample for the delimiter before `EI`.
|
||||
#[test]
|
||||
fn both_parsers_keep_a_last_sample_that_is_white_space() {
|
||||
for samples in [b" EI ", b"\x10\x20\x30\x20", b"\x10\x20\x30\x0A"] {
|
||||
let (c, h) = inline_bytes(&inline_gray(samples, b"q Q"));
|
||||
assert_eq!(c.as_deref(), Some(&samples[..]), "content crate: {samples:02x?}");
|
||||
assert_eq!(h.as_deref(), Some(&samples[..]), "hayro: {samples:02x?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_images_agree_when_ei_is_delimited() {
|
||||
agree("raw samples", &inline_gray(b"ABCD", b"BT (t) Tj ET"), &["q", "BI", "Q", "BT", "Tj", "ET"]);
|
||||
agree("ASCIIHex with a space before EI", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41> EI Q BT (t) Tj ET", &["q", "BI", "Q", "BT", "Tj", "ET"]);
|
||||
agree("ASCII85 with a space before EI", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /A85 ID 5l~> EI Q BT (t) Tj ET", &["q", "BI", "Q", "BT", "Tj", "ET"]);
|
||||
agree("two images in a row", b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A\nEI BI /W 1 /H 1 /BPC 8 /CS /G ID B\nEI Q", &["q", "BI", "BI", "Q"]);
|
||||
agree("a comment inside the parameters", b"q BI /W 1 % w\n/H 1 /BPC 8 /CS /G ID A\nEI Q", &["q", "BI", "Q"]);
|
||||
// A fake ` EI ` inside the samples with no text operator after the image: hayro's look-ahead
|
||||
// keeps searching and finds the real EI, so both read the four bytes.
|
||||
agree("fake EI, nothing but q/Q after", &inline_gray(b" EI ", b"q Q"), &["q", "BI", "Q", "q", "Q"]);
|
||||
assert_eq!(content(&inline_gray(b" EI ", b"q Q"))[1].operands, [format!("{GRAY_4X1} data:20454920")]);
|
||||
}
|
||||
|
||||
/// #3107. Unfiltered samples that happen to hold ` EI ` followed by a text operator: hayro's
|
||||
/// look-ahead used to accept the first `EI` because text follows it, so its image held no
|
||||
/// samples and the rest of them ran as content (`EI` became an operator). Closed by #3438
|
||||
/// (f9d37c089): hayro now takes the length from `/W /H /BPC /CS`, as the content crate does, and
|
||||
/// both keep all four bytes (ISO 32000-2 §8.9.7). MuPDF and pdftotext show the text after.
|
||||
#[test]
|
||||
fn both_parsers_keep_a_fake_ei_inside_unfiltered_samples() {
|
||||
let data = inline_gray(b" EI ", b"BT (t) Tj ET");
|
||||
agree("fake EI, text after", &data, &["q", "BI", "Q", "BT", "Tj", "ET"]);
|
||||
assert_eq!(content(&data)[1].operands, [format!("{GRAY_4X1} data:20454920")]);
|
||||
}
|
||||
|
||||
/// #3108. `EI` written straight after the filter's end-of-data marker (`>EI`, `~>EI`): hayro
|
||||
/// ends the image there, and the content crate used to find no delimited `EI` and swallow the
|
||||
/// rest of the stream into the image. Closed by #3259 (the content crate accepts `EI` after the
|
||||
/// marker) and b72078871 (the content crate takes a filtered image's end from hayro's
|
||||
/// tokenizer): both end the image at the marker and read the text after, as MuPDF and pdftotext
|
||||
/// do.
|
||||
#[test]
|
||||
fn both_parsers_end_the_image_at_ei_glued_to_the_filters_end_marker() {
|
||||
for (name, data, dict, marker) in [
|
||||
("ASCIIHex", &b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41>EI Q BT (t) Tj ET"[..], "<</BPC 8 /CS /G /F /AHx /H 1 /W 1>>", "34313e"),
|
||||
("ASCII85", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /A85 ID 5l~>EI Q BT (t) Tj ET", "<</BPC 8 /CS /G /F /A85 /H 1 /W 1>>", "356c7e3e"),
|
||||
] {
|
||||
agree(name, data, &["q", "BI", "Q", "BT", "Tj", "ET"]);
|
||||
assert_eq!(content(data)[1].operands, [format!("{dict} data:{marker}")], "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// No issue filed. `EI` glued to the next operator (`EIQ`): the content crate swallows the rest
|
||||
/// of the stream into the image; hayro yields a `BI` with no image at all, skips to the next
|
||||
/// delimited token and reads on, so the `Q` glued to `EI` is lost too. MuPDF ends the whole
|
||||
/// stream ("syntax error after inline image").
|
||||
#[test]
|
||||
fn ei_glued_to_the_next_operator_loses_the_image_on_both_sides_differently() {
|
||||
let data = b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A EIQ BT (t) Tj ET";
|
||||
let c = content(data);
|
||||
assert_eq!(ops(&c), ["q", "BI"]);
|
||||
assert_eq!(c[1].operands, [format!("{GRAY_1X1} data:{}", hex(b"A EIQ BT (t) Tj ET"))]);
|
||||
assert_eq!(hayro(data), [tok("q", &[]), tok("BI", &[]), tok("BT", &[]), tok("Tj", &["(74)"]), tok("ET", &[])]);
|
||||
}
|
||||
|
||||
/// No issue filed. An inline image with no `EI` at all: the content crate makes the rest of the
|
||||
/// stream the image's data; hayro yields a `BI` with no image and then reads the samples and
|
||||
/// everything after them as operators. MuPDF ends the whole stream.
|
||||
#[test]
|
||||
fn an_inline_image_without_ei_is_data_to_the_content_crate_and_operators_to_hayro() {
|
||||
let data = b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A Q BT (t) Tj ET";
|
||||
let c = content(data);
|
||||
assert_eq!(ops(&c), ["q", "BI"]);
|
||||
assert_eq!(c[1].operands, [format!("{GRAY_1X1} data:{}", hex(b"A Q BT (t) Tj ET"))]);
|
||||
assert_eq!(ops(&hayro(data)), ["q", "BI", "A", "Q", "BT", "Tj", "ET"]);
|
||||
}
|
||||
|
||||
/// No issue filed. Parameters that can't be read (`/W` with no value): the content crate skips
|
||||
/// to the `EI` and goes on; hayro ends its stream. MuPDF reports the dictionary and goes on.
|
||||
#[test]
|
||||
fn an_inline_image_with_unreadable_parameters_is_skipped_by_the_content_crate_and_ends_hayros_stream() {
|
||||
let data = b"q BI /W ID A\nEI Q";
|
||||
assert_eq!(ops(&content(data)), ["q", "Q"]);
|
||||
assert_eq!(ops(&hayro(data)), ["q"]);
|
||||
}
|
||||
|
||||
/// One page, 612 × 792, whose content stream is `content`.
|
||||
fn one_page_pdf(content: &[u8]) -> Vec<u8> {
|
||||
let mut stream = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
|
||||
stream.extend_from_slice(content);
|
||||
stream.extend_from_slice(b"\nendstream");
|
||||
let objs: [&[u8]; 4] = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>",
|
||||
&stream,
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for off in offsets {
|
||||
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// End to end: the renderer draws all four samples of an unfiltered 4 × 1 gray inline image
|
||||
/// whose last sample is a white-space byte (0x20 or 0x0A), so hayro's computed length (#3438)
|
||||
/// reaches the pixels and no sample is taken for the delimiter before `EI`.
|
||||
#[test]
|
||||
fn the_renderer_draws_a_last_sample_that_is_white_space() {
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
for samples in [[0x20, 0x45, 0x49, 0x20], [0x10, 0x60, 0xB0, 0x20], [0x10, 0x60, 0xB0, 0x0A]] {
|
||||
// Each sample is a 100 × 100 pt block from x = 100, y = 300 (PDF space).
|
||||
let mut content = b"q 400 0 0 100 100 300 cm BI /W 4 /H 1 /BPC 8 /CS /G ID ".to_vec();
|
||||
content.extend_from_slice(&samples);
|
||||
content.extend_from_slice(b"\nEI Q");
|
||||
let pdf = std::sync::Arc::new(one_page_pdf(&content));
|
||||
let mut renderer = PageRenderer::new(pdf, RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(page.error.is_none(), "{samples:02x?}: {:?}", page.error);
|
||||
// Block centres: x = 150 + 100 i, raster y = 792 - 350 = 442.
|
||||
let drawn: Vec<u8> = (0..4u32)
|
||||
.map(|i| {
|
||||
let idx = ((442 * page.width + 150 + 100 * i) * 4) as usize;
|
||||
page.rgba.get(idx).copied().unwrap_or(0xFF)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(drawn, samples, "{samples:02x?}: the gray of each block");
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -415,10 +415,19 @@ impl Certificate {
|
||||
|
||||
/// Whether `key` verifies this certificate's signature.
|
||||
pub fn signed_by(&self, key: &PublicKey) -> bool {
|
||||
self.signed_by_cached(key, &mut None)
|
||||
}
|
||||
|
||||
/// [`Self::signed_by`] keeping the digest of the signed part in `digest`, so checking one
|
||||
/// certificate against many candidate keys hashes it once.
|
||||
fn signed_by_cached(&self, key: &PublicKey, digest: &mut Option<Vec<u8>>) -> bool {
|
||||
let Ok(alg) = Tlv::parse_all(&self.sig_alg) else { return false };
|
||||
match keys::signature_algorithm(&alg) {
|
||||
Ok((scheme @ keys::Scheme::Ed25519, _)) => key.verify_message(scheme, &self.tbs, &self.signature).unwrap_or(false),
|
||||
Ok((scheme, Some(digest))) => key.verify(scheme, digest, &digest.digest(&[&self.tbs]), &self.signature).unwrap_or(false),
|
||||
Ok((scheme, Some(d))) => {
|
||||
let hash = digest.get_or_insert_with(|| d.digest(&[&self.tbs]));
|
||||
key.verify(scheme, d, hash, &self.signature).unwrap_or(false)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -533,7 +542,8 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at:
|
||||
// CA certificates between `last` and the leaf: what a candidate's pathLenConstraint limits.
|
||||
let below = chain.len() - 1;
|
||||
let mut found = None;
|
||||
for c in pool.iter().filter(|c| c.subject.raw == last.issuer.raw && !chain.contains(c) && last.signed_by(&c.public_key)) {
|
||||
let mut digest = None;
|
||||
for c in pool.iter().filter(|c| c.subject.raw == last.issuer.raw && !chain.contains(c) && last.signed_by_cached(&c.public_key, &mut digest)) {
|
||||
// Each reason is a whole sentence, not a phrase stitched into one: a language puts the
|
||||
// two names where it needs them.
|
||||
let why = if !c.may_issue() {
|
||||
|
||||
@@ -628,8 +628,8 @@ pub struct DocView {
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
page_count: usize,
|
||||
/// Page heights in points (view space), for mapping text positions to scroll offsets.
|
||||
page_heights: Vec<f32>,
|
||||
/// Page sizes (width, height) in points (view space), for mapping text positions to scroll offsets.
|
||||
page_sizes: Vec<(f32, f32)>,
|
||||
/// Screen rects of the pages drawn last frame (hit-testing, tests, automation).
|
||||
screen_rects: Vec<(usize, Rect)>,
|
||||
screen_xforms: Vec<(usize, PageXform)>,
|
||||
@@ -1064,7 +1064,7 @@ impl DocView {
|
||||
viewport_w: 800.0,
|
||||
viewport_h: 600.0,
|
||||
page_count: info.pages.len(),
|
||||
page_heights: info.pages.iter().map(|p| p.height).collect(),
|
||||
page_sizes: info.pages.iter().map(|p| (p.width, p.height)).collect(),
|
||||
screen_rects: Vec::new(),
|
||||
screen_xforms: Vec::new(),
|
||||
screen_ppp: 1.0,
|
||||
@@ -1154,7 +1154,7 @@ impl DocView {
|
||||
self.invalidate_content();
|
||||
self.frame_geometry_changed = true;
|
||||
self.page_count = info.pages.len();
|
||||
self.page_heights = info.pages.iter().map(|p| p.height).collect();
|
||||
self.page_sizes = info.pages.iter().map(|p| (p.width, p.height)).collect();
|
||||
let last = self.page_count.saturating_sub(1);
|
||||
self.current = self.current.min(last);
|
||||
self.selected.retain(|p| *p <= last);
|
||||
|
||||
@@ -180,16 +180,40 @@ impl DocView {
|
||||
self.flash_match(p);
|
||||
}
|
||||
|
||||
/// The centre of the current match as a fraction of its displayed page `[x, y]` (0..1 from
|
||||
/// the top-left corner, after the view rotation, as `goto_point` takes it).
|
||||
fn match_centre(&self) -> Option<[f32; 2]> {
|
||||
let f = self.find.as_ref()?;
|
||||
let (p, r) = f.matches.get(f.current?)?;
|
||||
let glyphs = self.texts.get(p)?.glyphs.get(r.clone())?;
|
||||
let [x0, y0, x1, y1] = glyphs.iter().map(|g| g.rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])?;
|
||||
let &(w, h) = self.page_sizes.get(*p).filter(|(w, h)| *w > 0.0 && *h > 0.0)?;
|
||||
let (u, v) = ((((x0 + x1) / 2.0) / w).clamp(0.0, 1.0), (((y0 + y1) / 2.0) / h).clamp(0.0, 1.0));
|
||||
// Turned clockwise by the view rotation, as `PageInfo::dest_fraction` does.
|
||||
Some(match self.rotation % 360 {
|
||||
90 => [1.0 - v, u],
|
||||
180 => [1.0 - u, 1.0 - v],
|
||||
270 => [v, 1.0 - u],
|
||||
_ => [u, v],
|
||||
})
|
||||
}
|
||||
|
||||
fn flash_match(&mut self, page: usize) {
|
||||
let frac = self
|
||||
.find
|
||||
.as_ref()
|
||||
.and_then(|f| f.current.and_then(|c| f.matches.get(c)))
|
||||
.and_then(|(p, r)| self.texts.get(p).and_then(|t| t.glyphs.get(r.start)).map(|g| g.rect[1]))
|
||||
.zip(self.page_heights.get(page).copied())
|
||||
.zip(self.page_sizes.get(page).map(|&(_, h)| h))
|
||||
.map(|(y, h)| ((y - 60.0) / h).clamp(0.0, 1.0))
|
||||
.unwrap_or(0.0);
|
||||
self.goto = Some((page, frac));
|
||||
// Then put the match's centre at the window's centre, as Acrobat does (#3530) and as a
|
||||
// /FitR destination's rectangle is: a page wider or taller than the window no longer
|
||||
// leaves it off to one side or near an edge.
|
||||
if let Some([fx, fy]) = self.match_centre() {
|
||||
self.goto_point = Some((page, Some(fx), Some(fy), vec2(self.viewport_w, self.viewport_h) / 2.0));
|
||||
}
|
||||
self.current = page;
|
||||
self.reset_coarse_state();
|
||||
}
|
||||
|
||||
@@ -510,7 +510,8 @@ fn explicit_search_jumps_record_history_and_keep_match_destination() {
|
||||
// Chrome refreshes the idle page box; navigation must not overwrite a draft
|
||||
// that may still be focused. DocView does not own the editor's focus state.
|
||||
assert_eq!(v.page_input, "draft in progress");
|
||||
assert!(v.goto_point.is_none(), "an earlier link destination cannot override the search offset");
|
||||
let middle = vec2(v.viewport_w, v.viewport_h) / 2.0;
|
||||
assert_eq!(v.goto_point, Some((3, Some(40.0 / 300.0), Some(0.675), middle)), "the match centre goes to the middle of the viewport");
|
||||
v.go_to_match(1);
|
||||
assert_eq!(v.back, vec![0], "same-page matches do not duplicate history");
|
||||
assert!(v.view_history(false));
|
||||
@@ -524,6 +525,18 @@ fn explicit_search_jumps_record_history_and_keep_match_destination() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_centres_the_match_on_a_rotated_view() {
|
||||
// The match's centre is 40 pt from the left and 270 pt down a 300 × 400 page.
|
||||
for (rotation, fx, fy) in [(90, 1.0 - 0.675, 40.0 / 300.0), (180, 1.0 - 40.0 / 300.0, 1.0 - 0.675), (270, 0.675, 1.0 - 40.0 / 300.0)] {
|
||||
let mut v = history_search_view();
|
||||
v.rotation = rotation;
|
||||
v.find_step(true);
|
||||
let middle = vec2(v.viewport_w, v.viewport_h) / 2.0;
|
||||
assert_eq!(v.goto_point, Some((3, Some(fx), Some(fy), middle)), "view rotated {rotation}°");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_search_updates_do_not_record_history_or_discard_forward() {
|
||||
let mut v = history_search_view();
|
||||
|
||||
@@ -151,6 +151,7 @@ pub fn strip(doc: &mut Document) -> Result<Stripped, XfaError> {
|
||||
let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut todo: Vec<ObjRef> = Vec::new();
|
||||
let mut kids_seen = std::collections::HashSet::new();
|
||||
for r in fields.iter().filter_map(Object::as_ref) {
|
||||
if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS {
|
||||
todo.push(r);
|
||||
@@ -177,7 +178,9 @@ pub fn strip(doc: &mut Document) -> Result<Stripped, XfaError> {
|
||||
}
|
||||
})?;
|
||||
}
|
||||
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
|
||||
// An indirect /Kids array several fields share is read once: its entries are already queued.
|
||||
let shared_seen = d.get(b"Kids").and_then(Object::as_ref).is_some_and(|k| !kids_seen.insert(k));
|
||||
if !shared_seen && let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
|
||||
for k in kids.iter().filter_map(Object::as_ref) {
|
||||
if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS {
|
||||
todo.push(k);
|
||||
@@ -283,3 +286,34 @@ pub fn existing_layout(doc: &Document) -> Option<crate::Report> {
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fields_sharing_a_kids_array_still_lose_their_private_keys_once() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut kid = Dict::new();
|
||||
kid.set(CLICK_KEY.to_vec(), Object::Int(1));
|
||||
let kid = doc.add(Object::Dict(kid));
|
||||
let kids = doc.add(Object::Array(vec![Object::Ref(kid)]));
|
||||
let parents: Vec<Object> = (0..3)
|
||||
.map(|_| {
|
||||
let mut p = Dict::new();
|
||||
p.set(b"Kids".to_vec(), Object::Ref(kids));
|
||||
Object::Ref(doc.add(Object::Dict(p)))
|
||||
})
|
||||
.collect();
|
||||
let mut acro = Dict::new();
|
||||
acro.set(b"XFA".to_vec(), Object::Array(Vec::new()));
|
||||
acro.set(b"Fields".to_vec(), Object::Array(parents));
|
||||
let root = doc.root().unwrap();
|
||||
let mut catalog = doc.get(root).as_dict().cloned().unwrap();
|
||||
catalog.set(b"AcroForm".to_vec(), Object::Dict(acro));
|
||||
doc.set(root, Object::Dict(catalog));
|
||||
let out = strip(&mut doc).unwrap();
|
||||
assert_eq!(out.script_buttons, 1);
|
||||
assert!(!doc.get(kid).as_dict().unwrap().contains(CLICK_KEY));
|
||||
}
|
||||
}
|
||||
@@ -1907,8 +1907,8 @@ milestone = "M7"
|
||||
status = "partial"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"]
|
||||
evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/testsLine truncated
|
||||
notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels."
|
||||
evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/testsLine truncated
|
||||
notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped; replacing one line keeps the source font for every word it can show and sets only the other words in Helvetica (#2378), while a paragraph rewrite still substitutes the whole paragraph for a Latin character its subset lacks); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels."
|
||||
|
||||
[[feature]]
|
||||
id = "edit.text-reflow"
|
||||
|
||||
Reference in new issue
Block a user