mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 06:28:49 -06:00
fix(render): bound the replacement text copied into redraws
Each unmarked redraw of an /ActualText span's glyphs took its own copy of the replacement text. The work budget counts comparisons, not bytes, so a long replacement around one glyph followed by many redraws at the same place copied it once per redraw: gigabytes from a small page. Redraws on one page share 4 MiB of replacement text; past it a redraw keeps its own glyphs, as it does past the work budget.
This commit is contained in:
@@ -420,6 +420,15 @@ const MAX_CELLS: i64 = 4096;
|
||||
/// their unmarked redraws (the text reads twice, as it would without `/ActualText`).
|
||||
const MAX_REDRAW_WORK: usize = 1 << 22;
|
||||
|
||||
/// Most replacement text bytes the redraw dedupe in `TextDevice::finish` copies on one page.
|
||||
const MAX_REDRAW_TEXT: usize = 1 << 22;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Replacement text bytes given to redraws on this thread.
|
||||
static REDRAW_TEXT_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn cell(v: f32) -> i64 {
|
||||
// Glyph boxes are finite (`draw_glyph` and `merge` drop the rest); `as` saturates.
|
||||
(v / CELL).floor() as i64
|
||||
@@ -564,6 +573,7 @@ impl TextDevice {
|
||||
redraws.sort_by_key(|(range, _)| range.start);
|
||||
let glyphs = std::mem::take(&mut self.glyphs);
|
||||
let mut redraws = redraws.into_iter().peekable();
|
||||
let mut text_left = MAX_REDRAW_TEXT;
|
||||
let mut i = 0;
|
||||
while let Some(g) = glyphs.get(i) {
|
||||
let Some((range, k)) = redraws.next_if(|(range, _)| range.start == i) else {
|
||||
@@ -572,7 +582,18 @@ impl TextDevice {
|
||||
continue;
|
||||
};
|
||||
let run = glyphs.get(range.clone()).unwrap_or_default();
|
||||
let text = self.replaced.get(k).map(|r| r.text.clone()).unwrap_or_default();
|
||||
let replacement = self.replaced.get(k).map_or("", |r| r.text.as_str());
|
||||
// Each redraw takes its own copy of the replacement, and one long replacement can
|
||||
// have many redraws: past the budget a redraw keeps its glyphs, as past the work budget.
|
||||
let Some(left) = text_left.checked_sub(replacement.len()) else {
|
||||
self.glyphs.extend_from_slice(run);
|
||||
i = range.end.max(i + 1);
|
||||
continue;
|
||||
};
|
||||
text_left = left;
|
||||
let text = replacement.to_owned();
|
||||
#[cfg(test)]
|
||||
REDRAW_TEXT_BYTES.with(|c| c.set(c.get() + text.len()));
|
||||
if !text.chars().all(|c| c.is_control()) {
|
||||
match merge(run, text) {
|
||||
Some(glyph) => self.glyphs.push(glyph),
|
||||
@@ -1871,6 +1892,27 @@ mod tests {
|
||||
extract_page(pdf, page, &InterpreterSettings::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redraws_of_a_long_replacement_share_a_text_budget() {
|
||||
let long = "0041".repeat(32 * 1024);
|
||||
let redraws = 200;
|
||||
let content = format!(
|
||||
"BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <</ActualText <FEFF{long}>>> BDC (x) Tj EMC {} ET",
|
||||
"1 0 0 1 100 150 Tm (x) Tj ".repeat(redraws)
|
||||
);
|
||||
let pdf = marked_pages(&[&content]);
|
||||
REDRAW_TEXT_BYTES.with(|c| c.set(0));
|
||||
let text = marked_text(&pdf, 0);
|
||||
let given = REDRAW_TEXT_BYTES.with(|c| c.get());
|
||||
assert!(given <= MAX_REDRAW_TEXT, "{given} bytes of replacement text given to {redraws} redraws");
|
||||
assert!(text.plain_text().starts_with("AAAA"));
|
||||
// A short replacement still covers its redraw.
|
||||
let pdf = marked_pages(&["BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <</ActualText (fi)>> BDC (x) Tj EMC 1 0 0 1 100 150 Tm (x) Tj ET"]);
|
||||
REDRAW_TEXT_BYTES.with(|c| c.set(0));
|
||||
assert_eq!(marked_text(&pdf, 0).plain_text(), "fi");
|
||||
assert_eq!(REDRAW_TEXT_BYTES.with(|c| c.get()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei() {
|
||||
// Everyday content both ways: a string with a byte above 0x7F, and a later word holding
|
||||
|
||||
Reference in New Issue
Block a user