Merge branch 'storytold:main' into main

This commit is contained in:
Combiler
2026-10-22 18:56:33 +00:00
committed by GitHub
9 changed files with 526 additions and 59 deletions
+69 -16
View File
@@ -182,17 +182,51 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] {
}
}
/// Where the marked content `ids` is drawn on a page (images, forms and paths).
fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64; 4]> {
/// An open marked-content sequence while bounds are gathered: its id, when it is one of those asked
/// for, and the bounds of what has been drawn in it so far.
struct Mark {
id: Option<i64>,
drawn: Option<[f64; 4]>,
}
/// Adds `b` to what the innermost open sequence has drawn.
fn draw(marks: &mut [Mark], b: [f64; 4]) {
if let Some(m) = marks.last_mut() {
m.drawn = Some(union(m.drawn, b));
}
}
/// Closes the innermost open sequence: what it drew counts for its id and for the sequence around it.
fn close(marks: &mut Vec<Mark>, matching: &mut usize, out: &mut HashMap<i64, [f64; 4]>) {
let Some(m) = marks.pop() else { return };
if m.id.is_some() {
*matching = matching.saturating_sub(1);
}
let Some(b) = m.drawn else { return };
if let Some(id) = m.id {
let e = out.entry(id).or_insert(b);
*e = union(Some(*e), b);
}
draw(marks, b);
}
/// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass
/// over the page. What a sequence draws counts for every sequence of `ids` around it too.
pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> HashMap<i64, [f64; 4]> {
let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let props = page_properties(doc, page);
#[cfg(test)]
crate::BOUNDS_PASSES.with(|c| c.set(c.get() + 1));
let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])];
let mut marks: Vec<bool> = Vec::new();
let mut bbox = None;
// The open marked-content sequences, and how many of them are one of `ids`.
let mut marks: Vec<Mark> = Vec::new();
let mut matching = 0usize;
let mut out = HashMap::new();
let mut path: Vec<(f64, f64)> = Vec::new();
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
let inside = marks.iter().any(|m| *m);
crate::count_mark_reads(1);
let inside = matching > 0;
let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
match op.op.as_slice() {
b"q" => ctm.push(top),
@@ -206,11 +240,13 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64;
*t = m.then(&top);
}
}
b"BMC" => marks.push(false),
b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))),
b"EMC" => {
marks.pop();
b"BMC" => marks.push(Mark { id: None, drawn: None }),
b"BDC" => {
let id = bdc_mcid(doc, &props, &op).filter(|n| ids.contains(n));
matching += usize::from(id.is_some());
marks.push(Mark { id, drawn: None });
}
b"EMC" => close(&mut marks, &mut matching, &mut out),
b"m" | b"l" => {
if let Some([x, y]) = op.nums::<2>() {
path.push(top.apply(x, y));
@@ -231,7 +267,7 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64;
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"n" => {
if inside && !matches!(op.op.as_slice(), b"n") {
for (x, y) in &path {
bbox = Some(union(bbox, [*x, *y, *x, *y]));
draw(&mut marks, [*x, *y, *x, *y]);
}
}
path.clear();
@@ -251,13 +287,16 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64;
}
_ => top.bbox([0.0, 0.0, 1.0, 1.0]),
};
bbox = Some(union(bbox, unit));
draw(&mut marks, unit);
}
b"BI" if inside => bbox = Some(union(bbox, top.bbox([0.0, 0.0, 1.0, 1.0]))),
b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])),
_ => {}
}
}
bbox
while !marks.is_empty() {
close(&mut marks, &mut matching, &mut out);
}
out
}
/// The figures, in document order.
@@ -266,14 +305,28 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let pages = pdfcraft_model::pages(doc);
let index: HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
elements(doc)
let figures: Vec<(Element, Option<usize>)> = elements(doc)
.into_iter()
.filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|e| {
let page = e.page.and_then(|p| index.get(&p).copied());
(e, page)
})
.collect();
let mut wanted: HashMap<usize, HashSet<i64>> = HashMap::new();
for (e, page) in &figures {
if let Some(p) = page {
wanted.entry(*p).or_default().extend(mcids(&e.dict));
}
}
let bounds: HashMap<usize, HashMap<i64, [f64; 4]>> =
wanted.into_iter().filter_map(|(p, ids)| Some((p, content_bboxes(doc, &pages.get(p)?.dict, &ids)))).collect();
figures
.into_iter()
.map(|(e, page)| {
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let ids: HashSet<i64> = mcids(&e.dict).into_iter().collect();
let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids));
let on_page = page.and_then(|p| bounds.get(&p));
let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b))));
Figure { obj: e.object, page, alt, bbox }
})
.collect()
+19 -14
View File
@@ -182,6 +182,7 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
let res = sub(doc, page, b"Resources");
let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties"));
let mut metrics: HashMap<Vec<u8>, Metrics> = HashMap::new();
// The id each open marked-content sequence is in: its own, else the enclosing one's.
let mut stack: Vec<Option<i64>> = Vec::new();
let mut out: HashMap<i64, String> = HashMap::new();
// Where each id's last glyph ended, in user space.
@@ -191,16 +192,20 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
let mut unsaved = 0usize;
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
let id = stack.iter().rev().find_map(|m| *m);
crate::count_mark_reads(1);
let id = stack.last().copied().flatten();
match op.op.as_slice() {
b"BMC" => stack.push(None),
b"BDC" => stack.push(match op.operands.get(1) {
Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int),
Some(Object::Name(n)) => {
props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int)))
b"BMC" => stack.push(id),
b"BDC" => stack.push(
match op.operands.get(1) {
Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int),
Some(Object::Name(n)) => {
props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int)))
}
_ => None,
}
_ => None,
}),
.or(id),
),
b"EMC" => {
stack.pop();
}
@@ -280,17 +285,17 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
};
for (code, len) in m.codes(&s.bytes) {
let trm = tm.then(&gs.ctm);
// A glyph's text is only built while its id still takes more.
if let Some(id) = id
&& out.get(&id).is_none_or(|t| t.len() < MAX_TITLE * 4)
&& let Some(glyph) = m.text_of(code)
{
let text = out.entry(id).or_default();
if text.len() < MAX_TITLE * 4 {
let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale));
if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') {
text.push(' ');
}
text.push_str(&glyph);
let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale));
if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') {
text.push(' ');
}
text.push_str(&glyph);
}
let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 };
tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm);
+16
View File
@@ -21,6 +21,22 @@ mod report;
mod structure;
mod tags;
#[cfg(test)]
thread_local! {
/// Marked-content stack entries read to decide which marks enclose an operator, for the tests
/// that keep that constant per operator.
static MARK_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Page content passes made to find figure bounds.
static BOUNDS_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Counts `n` marked-content stack reads (tests only).
#[inline]
fn count_mark_reads(_n: usize) {
#[cfg(test)]
MARK_READS.with(|c| c.set(c.get().saturating_add(_n)));
}
pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt};
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
pub use autotag_fields::tag_form_fields;
+61
View File
@@ -1023,6 +1023,67 @@ fn glyph_by_tm(text: &str, size: f64, y: f64) -> String {
ops + " ET"
}
/// Marked-content stack reads while `f` runs.
fn mark_reads(f: impl FnOnce()) -> usize {
MARK_READS.with(|c| c.set(0));
f();
MARK_READS.with(|c| c.get())
}
/// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside.
fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict {
use pdfcraft_cos::{Dict, Stream};
let mut data = "/Figure << /MCID 0 >> BDC ".to_string();
data.push_str(&"/X BMC ".repeat(depth));
data.push_str(&"0 0 1 1 re f ".repeat(ops));
data.push_str(&"EMC ".repeat(depth + 1));
let content = doc.add(Stream::flate(Dict::new(), data.as_bytes()));
[(b"Contents".to_vec(), Object::Ref(content))].into_iter().collect()
}
#[test]
fn marked_text_reads_its_enclosing_id_in_constant_steps() {
let mut doc = Document::new_empty();
let page = deeply_nested_page(&mut doc, 5_000, 5_000);
let reads = mark_reads(|| {
crate::headings::mcid_texts(&doc, &page);
});
// About 15 000 operators; a scan of the stack per operator reads millions of entries.
assert!(reads <= 4 * 15_000, "{reads} stack reads");
}
#[test]
fn figure_bounds_read_the_marked_content_state_in_constant_steps() {
let mut doc = Document::new_empty();
let page = deeply_nested_page(&mut doc, 5_000, 5_000);
// No mark encloses figure 7, so a scan of the stack per operator reads all of it.
let reads = mark_reads(|| {
crate::alt::content_bboxes(&doc, &page, &std::collections::HashSet::from([7]));
});
assert!(reads <= 4 * 15_000, "{reads} stack reads");
}
#[test]
fn figure_bounds_take_one_pass_over_each_page() {
let n = 50;
let content: Vec<String> = (0..n).map(|i| format!("/Figure << /MCID {i} >> BDC {i} 0 1 1 re f EMC")).collect();
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 6 + i)).collect();
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(),
stream(&content.join(" ")),
format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")),
];
objs.extend((0..n).map(|i| format!("<< /S /Figure /Pg 3 0 R /K {i} >>")));
let doc = pdf(&objs, "");
BOUNDS_PASSES.with(|c| c.set(0));
let f = figures(&doc);
assert_eq!(BOUNDS_PASSES.with(|c| c.get()), 1, "page passes");
assert_eq!(f.len(), n);
assert!(f.iter().enumerate().all(|(i, f)| f.bbox == Some([i as f64, 0.0, i as f64 + 1.0, 1.0])));
}
/// One tagged page whose H1 headings show `contents` (one marked-content sequence each).
fn heading_page(contents: &[String]) -> Document {
let marked: Vec<String> = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect();
+70 -5
View File
@@ -8,6 +8,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::HashMap;
mod pptx;
mod zip;
@@ -85,14 +87,33 @@ enum Item<'a> {
PageBreak,
}
#[cfg(test)]
thread_local! {
/// Entries read to find a font size's or a colour's slot, for the tests that keep it constant.
static LOOKUP_STEPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Counts `n` entries read by a lookup (tests only).
#[inline]
fn count_lookup_steps(_n: usize) {
#[cfg(test)]
LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n)));
}
/// The body text size: the size most of the document's characters are set in.
fn body_size(pages: &[Page]) -> f64 {
// Sizes in first-seen order (which breaks ties), and where each one is in it.
let mut by: Vec<(i64, usize)> = Vec::new();
let mut slot: HashMap<i64, usize> = HashMap::new();
for b in pages.iter().flat_map(|p| &p.blocks) {
let k = (b.size * 2.0).round() as i64;
match by.iter_mut().find(|e| e.0 == k) {
Some(e) => e.1 += b.text.len(),
None => by.push((k, b.text.len())),
count_lookup_steps(1);
let i = *slot.entry(k).or_insert_with(|| {
by.push((k, 0));
by.len() - 1
});
if let Some(e) = by.get_mut(i) {
e.1 = e.1.saturating_add(b.text.len());
}
}
by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0)
@@ -831,9 +852,12 @@ pub fn rtf(pages: &[Page]) -> String {
let items = items(pages);
// The colour table: entry 0 is the default (auto) colour, then each non-black colour once.
let mut colors: Vec<[u8; 3]> = Vec::new();
let mut slot: HashMap<[u8; 3], usize> = HashMap::new();
let mut note = |c: [f64; 3]| {
let c = rgb8(c);
if c != [0, 0, 0] && !colors.contains(&c) {
count_lookup_steps(1);
if c != [0, 0, 0] && !slot.contains_key(&c) {
slot.insert(c, colors.len());
colors.push(c);
}
};
@@ -847,7 +871,8 @@ pub fn rtf(pages: &[Page]) -> String {
// `\cfN` for a colour (nothing for black, which keeps the default).
let cf = |c: [f64; 3]| {
let c = rgb8(c);
colors.iter().position(|e| *e == c).map_or(String::new(), |i| format!("\\cf{}", i + 1))
count_lookup_steps(1);
slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1))
};
let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n");
if !colors.is_empty() {
@@ -911,6 +936,46 @@ pub fn rtf(pages: &[Page]) -> String {
mod tests {
use super::*;
/// A page of `n` one-word blocks, each in its own size and colour.
fn varied_page(n: usize) -> Page {
let blocks = (0..n)
.map(|i| Block {
text: "word".into(),
rect: [72.0, i as f64, 100.0, i as f64 + 1.0],
size: 4.0 + i as f64 * 0.5,
bold: false,
italic: false,
color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5],
rotation: 0.0,
})
.collect();
Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }
}
fn lookup_steps(f: impl FnOnce()) -> usize {
LOOKUP_STEPS.with(|c| c.set(0));
f();
LOOKUP_STEPS.with(|c| c.get())
}
#[test]
fn many_distinct_sizes_are_counted_in_constant_steps_each() {
let pages = [varied_page(3000)];
let steps = lookup_steps(|| {
body_size(&pages);
});
assert!(steps <= 2 * 3000, "{steps} steps");
}
#[test]
fn many_distinct_colours_take_constant_steps_each() {
let pages = [varied_page(3000)];
let mut out = String::new();
let steps = lookup_steps(|| out = rtf(&pages));
assert!(steps <= 4 * 3000, "{steps} steps");
assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot");
}
fn page() -> Page {
let b = |t: &str, y: f64, size: f64, bold: bool| Block {
text: t.into(),
+35 -4
View File
@@ -88,17 +88,28 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
}
/// [`wrap`], measuring with `measure` (for example [`cjk_width`]).
///
/// A line measures as the sum of its words, the spaces between them and the characters of a broken
/// word, each measured once, so the work stays linear in `text` however much fits on a line.
pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec<String> {
let mut lines = Vec::new();
let space = measure(" ", size);
let mut buf = [0u8; 4];
// A CRLF pair is one break, a lone CR or LF is one break each.
for para in text.replace("\r\n", "\n").split(['\n', '\r']) {
let mut line = String::new();
let mut line_width = 0.0;
// No word is on the line yet; an empty word (a leading space) still starts it.
let mut fresh = true;
for word in para.split(' ') {
let candidate = if fresh { word.to_string() } else { format!("{line} {word}") };
if measure(&candidate, size) <= width || fresh && measure(word, size) <= width {
line = candidate;
let word_width = measure(word, size);
let candidate = if fresh { word_width } else { line_width + space + word_width };
if candidate <= width {
if !fresh {
line.push(' ');
}
line.push_str(word);
line_width = candidate;
fresh = false;
continue;
}
@@ -108,11 +119,15 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64)
} else {
lines.push(std::mem::take(&mut line));
}
line_width = 0.0;
for ch in word.chars() {
if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width {
let ch_width = measure(ch.encode_utf8(&mut buf), size);
if !line.is_empty() && line_width + ch_width > width {
lines.push(std::mem::take(&mut line));
line_width = 0.0;
}
line.push(ch);
line_width += ch_width;
}
fresh = line.is_empty();
}
@@ -442,4 +457,20 @@ mod tests {
let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width);
assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}");
}
/// Wrapping measures each word and character a bounded number of times, however many fit on a line.
#[test]
fn wrapping_measures_each_word_a_few_times() {
let measured = std::cell::Cell::new(0usize);
let measure = |s: &str, size: f64| {
measured.set(measured.get() + s.len());
helvetica_width(s, size)
};
let text = format!("{} {}", "word ".repeat(5_000), "x".repeat(5_000));
let lines = wrap_with(&text, 1.0, 13_000.0, measure);
assert_eq!(lines.len(), 2, "the words fit on one line, the long word on another");
assert!(measured.get() <= 4 * text.len(), "{} bytes measured", measured.get());
let narrow = wrap_with(&text, 10.0, 100.0, helvetica_width);
assert!(narrow.iter().all(|l| helvetica_width(l, 10.0) <= 100.0), "{narrow:?}");
}
}
+162 -19
View File
@@ -33,9 +33,9 @@ pub struct Metrics {
widths: Vec<f64>,
/// Composite fonts: CID → width, and CID ranges with one width.
cid_widths: HashMap<u32, f64>,
cid_ranges: Vec<(u32, u32, f64)>,
cid_ranges: Spans<f64>,
/// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`).
cid_map: Vec<(u32, u32, u32)>,
cid_map: Spans<u32>,
default: f64,
/// Composite fonts with an explicit `/DW`: `/W` may leave out the glyphs that have that
/// width (Chrome and Skia do), so a CID missing from `/W` is not a missing glyph.
@@ -598,8 +598,8 @@ impl Metrics {
first: 0,
widths: Vec::new(),
cid_widths: HashMap::new(),
cid_ranges: Vec::new(),
cid_map: Vec::new(),
cid_ranges: Spans::default(),
cid_map: Spans::default(),
default: 500.0,
explicit_default: false,
std14: Some(crate::Std14::Helvetica),
@@ -685,7 +685,7 @@ impl Metrics {
if !spaces.is_empty() {
m.codes = Codes::Ranges(spaces);
}
m.cid_map = cids;
m.cid_map = Spans::new(cids);
}
let desc = font
.get(b"DescendantFonts")
@@ -698,6 +698,7 @@ impl Metrics {
m.explicit_default = true;
}
if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) {
let mut ranges = Vec::new();
let mut i = 0;
while i < w.len() {
let Some(c0) = doc.resolve(&w[i]).as_f64().map(|v| v as u32) else { break };
@@ -713,14 +714,17 @@ impl Metrics {
i += 2;
}
Some(c1) => {
if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) {
m.cid_ranges.push((c0, c1 as u32, v));
if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64()))
&& ranges.len() < MAX_CMAP_ENTRIES
{
ranges.push((c0, c1 as u32, v));
}
i += 3;
}
None => break,
}
}
m.cid_ranges = Spans::new(ranges);
}
descriptor = dict(doc, desc.get(b"FontDescriptor"));
} else {
@@ -791,14 +795,14 @@ impl Metrics {
if self.cid_map.is_empty() {
return code;
}
self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo))
self.cid_map.find(code).map_or(0, |(lo, _, c)| c.saturating_add(code.saturating_sub(*lo)))
}
/// The advance of `code` in text space per unit of font size.
pub fn width(&self, code: u32) -> f64 {
if self.composite {
let cid = self.cid(code);
let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.iter().find(|(a, b, _)| cid >= *a && cid <= *b).map(|r| r.2));
let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.find(cid).map(|r| r.2));
return w.unwrap_or(self.default) * self.scale;
}
if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) {
@@ -858,7 +862,7 @@ impl Metrics {
// A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for
// those, only the subset's program says whether the glyph is there.
return self.cid_widths.contains_key(&cid)
|| self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b)
|| self.cid_ranges.find(cid).is_some()
|| !self.subset
|| self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1)
// No readable TrueType program to ask (e.g. CFF or missing): an explicit /DW
@@ -905,9 +909,13 @@ impl Metrics {
}
}
/// The UTF-16 code units of CMap hex bytes.
/// Most UTF-16 units kept from one ToUnicode destination: the CMap format allows destinations of up
/// to 512 bytes, and every glyph a sequential range covers builds its text from the whole of one.
const MAX_DESTINATION_UNITS: usize = 256;
/// The UTF-16 code units of CMap hex bytes, at most [`MAX_DESTINATION_UNITS`] of them.
fn code_units(b: &[u8]) -> Vec<u16> {
b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect()
b.chunks(2).take(MAX_DESTINATION_UNITS).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect()
}
/// UTF-16BE (with surrogates) from CMap hex bytes.
@@ -954,16 +962,69 @@ fn add_range(out: &mut Unicode, budget: &mut usize, seq: &mut u32, lo: u32, hi:
true
}
#[cfg(test)]
thread_local! {
/// Code span records read by CID and width lookups, for the tests that keep a lookup logarithmic.
static SPAN_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Counts one span record read (tests only).
#[inline]
fn count_span_reads() {
#[cfg(test)]
SPAN_READS.with(|c| c.set(c.get().saturating_add(1)));
}
/// Code spans `(lo, hi, value)` in declaration order, where the first-declared span covering a code
/// wins, indexed so that a lookup is one binary search.
#[derive(Clone, Debug, Default, PartialEq)]
struct Spans<T> {
spans: Vec<(u32, u32, T)>,
/// The span covering each stretch of codes; see [`stretches`].
cover: Vec<(u64, Option<usize>)>,
}
impl<T> Spans<T> {
fn new(spans: Vec<(u32, u32, T)>) -> Self {
let bounds: Vec<(u32, u32)> = spans.iter().map(|s| (s.0, s.1)).collect();
Spans { cover: stretches(&bounds, false), spans }
}
fn is_empty(&self) -> bool {
self.spans.is_empty()
}
/// The first-declared span covering `code`.
fn find(&self, code: u32) -> Option<&(u32, u32, T)> {
let step = self.cover.partition_point(|&(start, _)| {
count_span_reads();
start <= u64::from(code)
});
let &(_, record) = self.cover.get(step.checked_sub(1)?)?;
self.spans.get(record?)
}
}
/// For each stretch of codes, from its first code on, the latest-declared range covering it (`None`
/// where no range does), sorted by code; a lookup is one binary search over it.
fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option<usize>)> {
let spans: Vec<(u32, u32)> = ranges.iter().map(|r| (r.lo, r.hi)).collect();
stretches(&spans, true)
}
/// For each stretch of codes, from its first code on, the span `(lo, hi)` covering it (`None` where
/// none does), sorted by code: the latest-declared one when `later_wins`, else the first-declared.
/// At most `u32::MAX` spans are indexed.
fn stretches(spans: &[(u32, u32)], later_wins: bool) -> Vec<(u64, Option<usize>)> {
// Heap keys, largest for the winning record; the mapping is its own inverse.
let key = |record: u32| if later_wins { record } else { !record };
// Openings (first code, record) and closings (one past the last code), each in code order. The cover
// can only change at one of their codes, so the sweep visits those codes in order.
let mut opens: Vec<(u32, u32)> = ranges.iter().enumerate().map(|(i, r)| (r.lo, i as u32)).collect();
let mut opens: Vec<(u32, u32)> = spans.iter().zip(0..u32::MAX).map(|(&(lo, _), i)| (lo, i)).collect();
opens.sort_unstable();
let mut closes: Vec<u64> = ranges.iter().map(|r| u64::from(r.hi) + 1).collect();
let mut closes: Vec<u64> = spans.iter().take(opens.len()).map(|&(_, hi)| u64::from(hi) + 1).collect();
closes.sort_unstable();
// Records that have opened and not yet closed; the top is the latest declared one.
// Keys of the records that have opened and not yet closed; the top is the winning one.
let mut open: BinaryHeap<u32> = BinaryHeap::new();
let mut cover: Vec<(u64, Option<usize>)> = Vec::new();
let (mut next_open, mut next_close) = (0, 0);
@@ -977,16 +1038,16 @@ fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option<usize>)> {
while let Some(&(lo, record)) = opens.get(next_open)
&& u64::from(lo) <= p
{
open.push(record);
open.push(key(record));
next_open += 1;
}
while closes.get(next_close).is_some_and(|&end| end <= p) {
next_close += 1;
}
while open.peek().is_some_and(|&top| ranges.get(top as usize).is_none_or(|r| u64::from(r.hi) < p)) {
while open.peek().is_some_and(|&top| spans.get(key(top) as usize).is_none_or(|&(_, hi)| u64::from(hi) < p)) {
open.pop();
}
let winner = open.peek().map(|&top| top as usize);
let winner = open.peek().map(|&top| key(top) as usize);
if cover.last().map(|&(_, w)| w) != Some(winner) {
cover.push((p, winner));
}
@@ -1262,6 +1323,88 @@ mod tests {
use super::*;
/// Span records read while `f` runs.
fn span_reads(f: impl FnOnce()) -> usize {
SPAN_READS.with(|c| c.set(0));
f();
SPAN_READS.with(|c| c.get())
}
/// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`.
fn composite(doc: &mut Document, encoding: &str, w: Vec<Object>, to_unicode: &str) -> Dict {
let mut desc = Dict::new();
desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
desc.set(b"W".to_vec(), Object::Array(w));
let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes()))));
let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes()))));
font(
doc,
vec![
("Subtype", Object::name("Type0")),
("Encoding", enc),
("DescendantFonts", Object::Array(vec![Object::Dict(desc)])),
("ToUnicode", tu),
],
)
}
/// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all.
#[test]
fn widths_of_a_code_no_range_covers_take_few_steps() {
let mut doc = Document::new_empty();
let w: Vec<Object> = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect();
let f = composite(&mut doc, "", w, "");
let m = Metrics::from_dict(&doc, &f);
assert_eq!(m.width(4), 0.5);
let reads = span_reads(|| {
for _ in 0..1000 {
assert_eq!(m.width(3), 1.0);
}
});
assert!(reads <= 1000 * 64, "{reads} span reads");
}
/// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it.
#[test]
fn overlapping_spans_resolve_to_the_first_declared() {
let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]);
let found = |code| spans.find(code).map(|s| s.2);
let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))];
for (code, value) in want {
assert_eq!(found(code), value, "code {code}");
}
}
/// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map.
#[test]
fn many_unicode_entries_look_up_their_cids_in_few_steps() {
let n = 2000;
let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect();
let encoding = format!("1 begincodespacerange <0000> <FFFF> endcodespacerange {n} begincidchar {cidchars} endcidchar");
let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect();
let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar");
let mut doc = Document::new_empty();
let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode);
let reads = span_reads(|| {
Metrics::from_dict(&doc, &f);
});
assert!(reads <= n * 64, "{reads} span reads");
}
/// Every glyph of a sequential range builds its text from the destination, so a long one is cut
/// to the CMap limit once, when it is read.
#[test]
fn unicode_destinations_are_cut_to_the_cmap_limit() {
let long = "0041".repeat(4096);
let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar");
let mut u = Unicode::default();
parse_to_unicode(cmap.as_bytes(), &mut u);
for code in [0x10, 0x20, 0x30, 0x40] {
let text = u.text(code).unwrap_or_default();
assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len());
}
}
/// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one.
#[test]
fn low_code_tables_end_with_the_last_covered_code() {
@@ -1718,7 +1861,7 @@ mod tests {
let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <zz> <0043>] endbfrange");
assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C")));
let mut m = Metrics::fallback();
(_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> <FFFF> 4294967295 endcidrange");
m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> <FFFF> 4294967295 endcidrange").1);
assert_eq!(m.cid(5), u32::MAX);
let mut doc = Document::new_empty();
let mut enc = Dict::new();
+55
View File
@@ -29,6 +29,19 @@ pub const MIN_FRAGMENTS: usize = 4;
/// Most pixels a merged image may have (memory).
const MAX_PIXELS: u64 = 80_000_000;
/// Fragment groups a page's search for runs may read per content operator, and at least. A run
/// that is not one picture is searched again from each of its groups, so without a limit images
/// drawn over one another take time quadratic in their number; past it, the rest of the page is
/// left as it is.
const SCAN_PER_OP: usize = 16;
const MIN_SCAN: usize = 1 << 16;
#[cfg(test)]
thread_local! {
/// Fragment groups read, for the test that keeps a page's search linear in its content.
static GROUP_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// One fragment: its image, its box in the coordinates the run is drawn in, and its size.
struct Fragment {
image: ObjRef,
@@ -40,6 +53,8 @@ struct Fragment {
/// The ops `q`, one or more `cm`, `Do`, `Q` starting at `i`: the index after them, the matrix
/// and the name drawn.
fn group_at(ops: &[Op], i: usize) -> Option<(usize, Matrix, Vec<u8>)> {
#[cfg(test)]
GROUP_READS.with(|c| c.set(c.get().saturating_add(1)));
if !ops.get(i)?.is("q") {
return None;
}
@@ -226,7 +241,13 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &
let ops = parsed.ops;
let mut out: Vec<Op> = Vec::with_capacity(ops.len());
let (mut i, mut merged, mut k) = (0, 0, 0);
let budget = ops.len().saturating_mul(SCAN_PER_OP).max(MIN_SCAN);
let mut scanned = 0usize;
while i < ops.len() {
if scanned > budget {
out.extend(ops.get(i..).unwrap_or_default().iter().cloned());
break;
}
// The longest run of fragment groups from here with one colour space.
let mut run = Vec::new();
let mut kind: Option<(Object, usize)> = None;
@@ -245,10 +266,12 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &
run.push(Fragment { image, rect: [e, f, e + a, f + d], width, height });
j = next;
}
scanned = scanned.saturating_add(run.len()).saturating_add(1);
// Not one picture as a whole: the rows drawn one under the other from the start may be.
if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) {
run = drawn_in_sequence(&ops, i, &xobjects, doc);
j = run_end(&ops, i, run.len());
scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2);
}
let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else {
// Not a run: keep this op and look again from the next one.
@@ -304,6 +327,8 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, pro
#[cfg(test)]
mod tests {
use pdfcraft_cos::{Dict, Document, Object, Stream};
use super::*;
#[test]
@@ -318,4 +343,34 @@ mod tests {
];
assert!(merge(&Document::new_empty(), &run, 3).is_none());
}
/// Many images drawn on top of each other are not one picture; looking for runs among them reads
/// a bounded number of groups per operator, not the rest of the page from every group.
#[test]
fn rejected_runs_read_a_bounded_number_of_groups() {
let mut doc = Document::new_empty();
let mut image = Dict::new();
for (k, v) in
[("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))]
{
image.set(k.as_bytes().to_vec(), v);
}
image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0])));
let groups = 4000;
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(groups).as_bytes())));
let mut xobjects = Dict::new();
xobjects.set(b"Im".to_vec(), Object::Ref(image));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
let mut page = Dict::new();
page.set(b"Contents".to_vec(), Object::Ref(content));
page.set(b"Resources".to_vec(), Object::Dict(resources));
let page = doc.add(Object::Dict(page));
GROUP_READS.with(|c| c.set(0));
let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true);
assert_eq!(merged.ok(), Some(0));
let reads = GROUP_READS.with(|c| c.get());
assert!(reads <= 40 * 4 * groups, "{reads} group reads");
}
}
+39 -1
View File
@@ -227,6 +227,19 @@ fn is_dct(s: &Stream) -> Option<bool> {
}
}
#[cfg(test)]
thread_local! {
/// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding.
static DECODED_PIXELS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Counts `n` decoded pixels (tests only).
#[inline]
fn count_decoded(_n: u64) {
#[cfg(test)]
DECODED_PIXELS.with(|c| c.set(c.get().saturating_add(_n)));
}
/// 8-bit samples of an image (`n` components), decoded.
pub(crate) fn pixels(s: &Stream, n: usize) -> Option<Vec<u8>> {
let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?);
@@ -235,7 +248,15 @@ pub(crate) fn pixels(s: &Stream, n: usize) -> Option<Vec<u8>> {
}
let bytes = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)?;
if is_dct(s)? {
let img = image::load_from_memory_with_format(&s.raw, image::ImageFormat::Jpeg).ok()?;
use image::ImageDecoder as _;
// The JPEG's own size is what gets decoded: one other than the dictionary's is refused from
// its header, before any pixels are.
let decoder = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&s.raw[..])).ok()?;
if decoder.dimensions() != (w, h) {
return None;
}
let img = image::DynamicImage::from_decoder(decoder).ok()?;
count_decoded(u64::from(img.width()) * u64::from(img.height()));
// Adobe CMYK/YCCK JPEGs aren't handled (the colour space check excludes them).
let v = if n == 1 { img.into_luma8().into_raw() } else { img.into_rgb8().into_raw() };
(v.len() == bytes).then_some(v)
@@ -432,4 +453,21 @@ mod dimension_tests {
let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]);
assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4]));
}
/// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded.
#[test]
fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() {
let data = jpeg(&vec![128; 1000 * 1000], 1, 1000, 1000, 50).unwrap_or_default();
let stream = |w: i64, h: i64| {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(w));
dict.set(b"Height".to_vec(), Object::Int(h));
dict.set(b"Filter".to_vec(), Object::name("DCTDecode"));
Stream::from_raw(dict, data.clone())
};
DECODED_PIXELS.with(|c| c.set(0));
assert!(pixels(&stream(10, 10), 1).is_none());
assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded");
assert_eq!(pixels(&stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000));
}
}