mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge upstream/main into fix/zero-area-fill-and-clip
Keeps main's vendor ledger with hayro-interpret patch (65) added, and main's BlendMode import beside the patch's kurbo imports in path.rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
commit
e75384ea90
560 files changed
+73922
-4676
No files matched your search
@@ -11,7 +11,9 @@ workspace = true
|
||||
[dependencies]
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
hayro = { workspace = true }
|
||||
# The usable-quad rule `/QuadPoints` readers share.
|
||||
pdfcraft-geom = { workspace = true }
|
||||
hayro ={ workspace = true }
|
||||
hayro-syntax = { workspace = true }
|
||||
# Picks the face of an installed font collection (the version pdfcraft-fonts already uses).
|
||||
skrifa = "0.44"
|
||||
@@ -29,5 +31,6 @@ bytemuck = { version = "1" }
|
||||
getrandom = { version = "0.4", features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
# Builds the decompression-bomb streams in the raster tests.
|
||||
flate2 = { workspace = true }
|
||||
@@ -53,6 +53,10 @@ pub struct DocInfo {
|
||||
pub keywords: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub producer: Option<String>,
|
||||
/// `/Info /CreationDate` and `/ModDate` for display ("2026-10-01 12:30", see [`pretty_date`]);
|
||||
/// a value that isn't a PDF date is shown as written.
|
||||
pub created: Option<String>,
|
||||
pub modified: Option<String>,
|
||||
/// Custom document properties: `/Info` entries with a key the spec does not name and a text
|
||||
/// value, as (name, text), in the dictionary's order (at most 256).
|
||||
pub custom: Vec<(String, String)>,
|
||||
|
||||
@@ -15,13 +15,38 @@ const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024;
|
||||
/// are not listed: real comments stay far below, many small ones sharing one long string do not.
|
||||
pub(super) const MAX_ANNOTATION_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Web-address text the link listing, and name, description and embedded-file key text the
|
||||
/// attachment listing, keep together. Many links or attachments can share one long string, which
|
||||
/// each would otherwise copy. Past it, a link to a web address lists as a URI action without its
|
||||
/// address, an attachment under its generic name without a description, and an embedded file whose
|
||||
/// key does not fit is not listed.
|
||||
/// Web-address text the bookmark and link listings, and name, description and embedded-file key
|
||||
/// text the attachment listing, keep together. Many bookmarks, links or attachments can share one
|
||||
/// long string, which each would otherwise copy. Past it, a bookmark or link to a web address lists
|
||||
/// as a URI action without its address, an attachment under its generic name without a
|
||||
/// description, and an embedded file whose key does not fit is not listed.
|
||||
pub(super) const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20;
|
||||
|
||||
/// A colour array as RGB, read as pdfcraft-annot's appearance builder reads it (`appearance::color`
|
||||
/// and `drawn_color`): one entry is grey, three RGB, four CMYK; among four entries a non-number is
|
||||
/// 0 but a reference is unreadable, otherwise every entry must be a number. `None` for anything
|
||||
/// else, an empty array or a value out of range; each component is clamped to 0..=1.
|
||||
fn drawn_color(c: &[Object]) -> Option<[f32; 3]> {
|
||||
let v: Vec<f32> = if c.len() == 4 {
|
||||
c.iter()
|
||||
.map(|o| match o {
|
||||
Object::Reference(_) => None,
|
||||
o => Some(o.as_float().unwrap_or(0.0)),
|
||||
})
|
||||
.collect::<Option<_>>()?
|
||||
} else {
|
||||
c.iter().map(|o| o.as_float().ok()).collect::<Option<_>>()?
|
||||
};
|
||||
let rgb = match v.as_slice() {
|
||||
[g] => [*g; 3],
|
||||
[r, g, b] => [*r, *g, *b],
|
||||
[c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
|
||||
_ => return None,
|
||||
};
|
||||
// pdfcraft-annot's `finite`: finite and below 1e7 in magnitude.
|
||||
rgb.iter().all(|x| x.is_finite() && x.abs() < 1e7).then(|| rgb.map(|x| x.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
/// The bytes a listed annotation keeps.
|
||||
pub(super) fn retained(a: &Annotation) -> usize {
|
||||
let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent];
|
||||
@@ -93,6 +118,16 @@ impl<'a> Inspector<'a> {
|
||||
let mut comments_full = false;
|
||||
for (page, index, a) in entries {
|
||||
let Some(d) = self.dict(a) else { continue };
|
||||
// An annotation's `/Type`, when present, is `/Annot` (ISO 32000-2 §12.5.2, Table 166):
|
||||
// a font or other dictionary a page lists among its annotations isn't one, and neither is
|
||||
// one whose `/Type` isn't a name at all. A null or dangling `/Type` counts as absent
|
||||
// (§7.3.9: a null value is the same as leaving the entry out).
|
||||
if let Ok(t) = d.get(b"Type").map(|t| self.resolve(t))
|
||||
&& !matches!(t, Object::Null | Object::Reference(_))
|
||||
&& t.as_name().ok() != Some(b"Annot".as_slice())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(subtype) = self.name(d, b"Subtype") else { continue };
|
||||
if subtype == "FileAttachment"
|
||||
&& let Ok(fs) = d.get(b"FS")
|
||||
@@ -119,13 +154,18 @@ impl<'a> Inspector<'a> {
|
||||
continue;
|
||||
}
|
||||
let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null)));
|
||||
let mut color = match d.get(b"C").map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(c)) if c.len() == 3 => {
|
||||
let f = |i: usize| c[i].as_float().unwrap_or(0.0);
|
||||
Some([f(0), f(1), f(2)])
|
||||
}
|
||||
let c = match d.get(b"C").map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(c)) => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
// Three numbers, some given by reference, with a non-number read as 0.
|
||||
let rgb = c.filter(|c| c.len() == 3).map(|c| {
|
||||
let f = |i: usize| c.get(i).map_or(0.0, |o| self.resolve(o).as_float().unwrap_or(0.0));
|
||||
[f(0), f(1), f(2)]
|
||||
});
|
||||
// Other comments list the stroke colour as pdfcraft-annot draws it and lists it
|
||||
// (`summaries`): grey, CMYK, a non-number among four entries read as 0.
|
||||
let mut color = if subtype == "FreeText" { rgb } else { c.and_then(|c| drawn_color(c)).or(rgb) };
|
||||
// A text box's /C is its background; its text colour is in /DA.
|
||||
if subtype == "FreeText"
|
||||
&& let Some(da) = self.text(d, b"DA")
|
||||
@@ -138,10 +178,12 @@ impl<'a> Inspector<'a> {
|
||||
color = Some([f(i - 3), f(i - 2), f(i - 1)]);
|
||||
}
|
||||
}
|
||||
// Only usable quads, by the rule redaction apply reads them with (keep in step with
|
||||
// `pdfcraft_annot::summaries`): a malformed one is not drawn as zeros.
|
||||
let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(q)) => {
|
||||
let v: Vec<f32> = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
|
||||
v.as_chunks::<8>().0.to_vec()
|
||||
let entries = q.iter().map(|o| self.resolve(o).as_float().ok().map(f64::from));
|
||||
pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|quad| quad.map(|v| v as f32)).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
@@ -192,35 +234,44 @@ impl<'a> Inspector<'a> {
|
||||
link_replies(&mut info.annotations);
|
||||
}
|
||||
|
||||
/// The link annotation `d`, at position `index` of page `page`'s `/Annots`: where its `/Dest`
|
||||
/// goes, else what its action `/A` does ([`Self::action_target`]). `None` when it has no
|
||||
/// rectangle, or a destination or action that leads nowhere.
|
||||
fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option<Link> {
|
||||
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
|
||||
let target = if let Ok(dest) = d.get(b"Dest") {
|
||||
let (page, view) = self.dest(dest, 0)?;
|
||||
LinkTarget::Page(page, view)
|
||||
} else {
|
||||
let a = d.get(b"A").ok().and_then(|a| self.dict(a))?;
|
||||
match self.name(a, b"S").as_deref() {
|
||||
Some("GoTo") => {
|
||||
let (page, view) = self.dest(a.get(b"D").ok()?, 0)?;
|
||||
LinkTarget::Page(page, view)
|
||||
}
|
||||
Some("URI") => {
|
||||
let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?;
|
||||
match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) {
|
||||
Some(uri) => LinkTarget::Uri(uri),
|
||||
None => LinkTarget::Other("URI".into()),
|
||||
}
|
||||
}
|
||||
Some("SetOCGState") => self.layer_state(a),
|
||||
Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())),
|
||||
Some("Launch") => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())),
|
||||
Some(other) => LinkTarget::Other(other.to_string()),
|
||||
None => return None,
|
||||
}
|
||||
self.action_target(d.get(b"A").ok().and_then(|a| self.dict(a))?, text_budget)?
|
||||
};
|
||||
Some(Link { page, index, rect, target })
|
||||
}
|
||||
|
||||
/// What an action does when a link or a bookmark runs it. Only a go-to's `/D` is a place in
|
||||
/// this document; a remote go-to's names a page or destination of another file (ISO 32000-2
|
||||
/// §12.6.4.3). `None` for an action without a type, or a go-to whose destination is not here.
|
||||
/// A web address is spent from `text_budget` (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]).
|
||||
pub(super) fn action_target(&self, a: &Dictionary, text_budget: &mut usize) -> Option<LinkTarget> {
|
||||
Some(match self.name(a, b"S").as_deref()? {
|
||||
"GoTo" => {
|
||||
let (page, view) = self.dest(a.get(b"D").ok()?, 0)?;
|
||||
LinkTarget::Page(page, view)
|
||||
}
|
||||
"URI" => {
|
||||
let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?;
|
||||
match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) {
|
||||
Some(uri) => LinkTarget::Uri(uri),
|
||||
None => LinkTarget::Other("URI".into()),
|
||||
}
|
||||
}
|
||||
"SetOCGState" => self.layer_state(a),
|
||||
"GoToR" => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())),
|
||||
"Launch" => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())),
|
||||
other => LinkTarget::Other(other.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn remote_go_to(&self, action: &Dictionary) -> Option<LinkTarget> {
|
||||
let file = self.action_file(action)?;
|
||||
let destination = match action.get(b"D") {
|
||||
@@ -338,14 +389,14 @@ impl<'a> Inspector<'a> {
|
||||
o: &'a Object,
|
||||
parent: Option<(&str, Option<String>, Option<i64>)>,
|
||||
out: &mut Vec<Field>,
|
||||
seen: &mut HashSet<ObjectId>,
|
||||
walk: &mut FieldWalk,
|
||||
depth: u32,
|
||||
) {
|
||||
if depth > 32 {
|
||||
return;
|
||||
}
|
||||
if let Object::Reference(id) = o
|
||||
&& !seen.insert(*id)
|
||||
&& !walk.seen.insert(*id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -364,10 +415,16 @@ impl<'a> Inspector<'a> {
|
||||
Ok(Object::Array(k)) => k.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect();
|
||||
let (field_kids, widget_kids): (Vec<&Object>, Vec<&Object>) = kids.iter().partition(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok()));
|
||||
let field_kids: Vec<_> = field_kids.into_iter().filter(|k| !self.not_a_child(walk, o, k)).collect();
|
||||
if !field_kids.is_empty() {
|
||||
let id = if let Object::Reference(id) = o { Some(*id) } else { None };
|
||||
walk.path.extend(id);
|
||||
for k in field_kids {
|
||||
self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1);
|
||||
self.field(k, Some((&full, ft.clone(), ff)), out, walk, depth + 1);
|
||||
}
|
||||
if id.is_some() {
|
||||
walk.path.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -397,14 +454,67 @@ impl<'a> Inspector<'a> {
|
||||
Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()),
|
||||
_ => None,
|
||||
};
|
||||
// The widget is either this dict (merged field/widget) or its first kid.
|
||||
let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) };
|
||||
let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, kids));
|
||||
// The widget is either this dict (merged field/widget) or its first kid. With no widget
|
||||
// among its kids, it is the first page widget naming this field as its `/Parent`: a child
|
||||
// names its parent (ISO 32000-2 §12.7.4.1, Table 226), as MuPDF and PDFium read it.
|
||||
let merged = d.get(b"Rect").is_ok();
|
||||
let named = if merged || !widget_kids.is_empty() { None } else { self.widget_naming(o) };
|
||||
let widget = if merged {
|
||||
Some(d)
|
||||
} else {
|
||||
widget_kids.first().and_then(|k| self.dict(k)).or_else(|| named.and_then(|id| self.doc.get_dictionary(id).ok()))
|
||||
};
|
||||
let page = widget
|
||||
.and_then(|w| w.get(b"P").ok())
|
||||
.and_then(|p| self.page_of(p))
|
||||
.or_else(|| self.find_widget_page(o, kids))
|
||||
.or_else(|| named.and_then(|id| self.annotation_pages.get_or_init(|| self.annotation_pages()).get(&id).copied()));
|
||||
let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok());
|
||||
let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r)));
|
||||
out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect });
|
||||
}
|
||||
|
||||
/// Whether `k`, listed in the `/Kids` of field `o`, is not a child of `o` after all: `o` itself
|
||||
/// or a field `o` descends from (a loop), or a root field (one `/Fields` lists) that doesn't
|
||||
/// name `o` as its `/Parent`. Root fields have no ancestors (ISO 32000-2 §12.7.3, Table 224),
|
||||
/// and a child names its parent (§12.7.4.1, Table 226).
|
||||
fn not_a_child(&self, walk: &FieldWalk, o: &Object, k: &Object) -> bool {
|
||||
let Object::Reference(k) = k else { return false };
|
||||
let parent = self.dict(&Object::Reference(*k)).and_then(|d| match d.get(b"Parent") {
|
||||
Ok(Object::Reference(p)) => Some(*p),
|
||||
_ => None,
|
||||
});
|
||||
let o = if let Object::Reference(o) = o { Some(*o) } else { None };
|
||||
o == Some(*k) || walk.path.contains(k) || (walk.roots.contains(k) && (o.is_none() || parent != o))
|
||||
}
|
||||
|
||||
/// The first page widget that names field `o` as its `/Parent` and is not a field of its own
|
||||
/// (no `/T`), in page-annotation order.
|
||||
fn widget_naming(&self, o: &Object) -> Option<ObjectId> {
|
||||
let Object::Reference(id) = o else { return None };
|
||||
self.parent_widgets.get_or_init(|| self.parent_widgets()).get(id).copied()
|
||||
}
|
||||
|
||||
/// The first page widget (no `/T`) naming each field as its `/Parent`, in page-annotation order.
|
||||
fn parent_widgets(&self) -> HashMap<ObjectId, ObjectId> {
|
||||
let mut first = HashMap::new();
|
||||
for &pid in &self.page_ids {
|
||||
let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue };
|
||||
let Ok(annots) = self.resolve(annots).as_array() else { continue };
|
||||
for a in annots {
|
||||
let Object::Reference(id) = a else { continue };
|
||||
let Ok(w) = self.doc.get_dictionary(*id) else { continue };
|
||||
if self.name(w, b"Subtype").as_deref() != Some("Widget") || w.get(b"T").is_ok() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(Object::Reference(parent)) = w.get(b"Parent") {
|
||||
first.entry(*parent).or_insert(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
/// The first page whose `/Annots` lists the field or one of its kids.
|
||||
fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option<usize> {
|
||||
let pages = self.annotation_pages.get_or_init(|| self.annotation_pages());
|
||||
@@ -426,7 +536,7 @@ impl<'a> Inspector<'a> {
|
||||
pages
|
||||
}
|
||||
|
||||
/// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an
|
||||
/// The topmost field above widget `id` (itself without a `/Parent` that is a field), or `None` when it or an
|
||||
/// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top.
|
||||
pub(super) fn unlisted_field(&self, id: ObjectId, seen: &HashSet<ObjectId>) -> Option<ObjectId> {
|
||||
let mut top = id;
|
||||
@@ -434,8 +544,10 @@ impl<'a> Inspector<'a> {
|
||||
loop {
|
||||
let o = Object::Reference(top);
|
||||
let d = self.dict(&o)?;
|
||||
// A `/Parent` naming an object the file doesn't hold, or no dictionary, reads as
|
||||
// `null`, the same as no parent (ISO 32000-2 §7.3.9).
|
||||
let parent = match d.get(b"Parent") {
|
||||
Ok(Object::Reference(p)) => Some(*p),
|
||||
Ok(Object::Reference(p)) if self.dict(&Object::Reference(*p)).is_some() => Some(*p),
|
||||
_ => None,
|
||||
};
|
||||
let Some(p) = parent else {
|
||||
@@ -452,6 +564,15 @@ impl<'a> Inspector<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What one reading of the form's fields keeps: the fields read, the root fields `/Fields` lists,
|
||||
/// and the fields whose `/Kids` are being read, outermost first.
|
||||
#[derive(Default)]
|
||||
pub(super) struct FieldWalk {
|
||||
pub(super) seen: HashSet<ObjectId>,
|
||||
pub(super) roots: HashSet<ObjectId>,
|
||||
path: Vec<ObjectId>,
|
||||
}
|
||||
|
||||
/// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a
|
||||
/// `u32` counts as no flags.
|
||||
fn draw_flags(o: &Object) -> u32 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES;
|
||||
use super::annotations::{FieldWalk, MAX_LINK_ATTACHMENT_TEXT_BYTES};
|
||||
use super::model::{AttachmentSource, PageInfo, Xfa};
|
||||
use super::navigation::text_string;
|
||||
use super::{
|
||||
@@ -80,6 +80,9 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
|
||||
Ok(p) => info.pages = p,
|
||||
Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))),
|
||||
}
|
||||
// Never silent: the pages of a file whose last update has no `/Prev` were found by rebuilding
|
||||
// its cross-reference data (`pdfcraft-cos` rebuilds it under the same conditions and logs it).
|
||||
let unlinked = pdf.rebuilt_unlinked_update();
|
||||
let options = load_options(password);
|
||||
// Geometry no longer needs the renderer parser. Do not overlap its allocations with
|
||||
// structural inspection, including the compatibility fallback for damaged files.
|
||||
@@ -110,9 +113,17 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
|
||||
if info.pages.is_empty() {
|
||||
return Err(OpenError::Invalid("the document has no pages".into()));
|
||||
}
|
||||
if unlinked {
|
||||
info.warnings.push(UNLINKED_UPDATE.into());
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// The warning for a file whose pages were found by rebuilding its cross-reference data because its
|
||||
/// last update has no `/Prev` (ISO 32000-2 §7.5.6, §7.5.8.2).
|
||||
const UNLINKED_UPDATE: &str =
|
||||
"This file's last update has no link (/Prev) to its earlier revisions; its pages were found by rebuilding the cross-reference data.";
|
||||
|
||||
// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback.
|
||||
pub(super) fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) {
|
||||
let structure = catch_unwind(AssertUnwindSafe(|| {
|
||||
@@ -144,13 +155,16 @@ pub(super) struct Inspector<'a> {
|
||||
/// has no `/P`. Searching every page's `/Annots` for each such field was O(fields ×
|
||||
/// annotations): a second on a form of 8,000 fields.
|
||||
pub(super) annotation_pages: OnceCell<HashMap<ObjectId, usize>>,
|
||||
/// The first page widget naming each field as its `/Parent`, built when a field has no widget
|
||||
/// among its `/Kids`.
|
||||
pub(super) parent_widgets: OnceCell<HashMap<ObjectId, ObjectId>>,
|
||||
}
|
||||
|
||||
impl<'a> Inspector<'a> {
|
||||
pub(super) fn new(doc: &'a dyn Structure) -> Self {
|
||||
let page_ids = doc.page_ids();
|
||||
let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect();
|
||||
let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() };
|
||||
let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new(), parent_widgets: OnceCell::new() };
|
||||
let mut named = HashMap::new();
|
||||
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))
|
||||
@@ -161,6 +175,9 @@ impl<'a> Inspector<'a> {
|
||||
me
|
||||
}
|
||||
|
||||
/// Reads what `info` holds beyond the page list: encryption, document information, tagging,
|
||||
/// page labels, bookmarks, annotations, transitions, form fields, layers, fonts, attachments,
|
||||
/// JavaScript and the opening destination.
|
||||
pub(super) fn fill(&self, info: &mut DocInfo) {
|
||||
// lopdf drops /Encrypt from the trailer once it has decrypted the file.
|
||||
info.encrypted = self.doc.encrypted();
|
||||
@@ -171,6 +188,14 @@ impl<'a> Inspector<'a> {
|
||||
info.keywords = self.text(d, b"Keywords");
|
||||
info.creator = self.text(d, b"Creator");
|
||||
info.producer = self.text(d, b"Producer");
|
||||
// Dates are text strings only (ISO 32000-2 §7.9.4), as the engine reads them after an
|
||||
// edit (`pdfcraft_organize::info`): a name value is no date.
|
||||
let date = |key: &[u8]| match self.resolve(d.get(key).ok()?) {
|
||||
Object::String(..) => self.text(d, key).map(|s| pretty_date(&s)),
|
||||
_ => None,
|
||||
};
|
||||
info.created = date(b"CreationDate");
|
||||
info.modified = date(b"ModDate");
|
||||
const STANDARD: [&[u8]; 9] =
|
||||
[b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"];
|
||||
// The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`,
|
||||
@@ -204,18 +229,19 @@ impl<'a> Inspector<'a> {
|
||||
Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))),
|
||||
Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")),
|
||||
}
|
||||
if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) {
|
||||
(info.outline, info.outline_more) = self.outline_window(first);
|
||||
}
|
||||
let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES;
|
||||
if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) {
|
||||
(info.outline, info.outline_more) = self.outline_window(first, &mut text_budget);
|
||||
}
|
||||
self.annotations(info, &mut text_budget);
|
||||
self.transitions(info);
|
||||
let mut field_seen = HashSet::new();
|
||||
let mut walk = FieldWalk::default();
|
||||
if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o))
|
||||
&& let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array())
|
||||
{
|
||||
walk.roots = fields.iter().filter_map(|f| if let Object::Reference(id) = f { Some(*id) } else { None }).collect();
|
||||
for f in fields {
|
||||
self.field(f, None, &mut info.fields, &mut field_seen, 0);
|
||||
self.field(f, None, &mut info.fields, &mut walk, 0);
|
||||
}
|
||||
}
|
||||
// Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them);
|
||||
@@ -231,9 +257,9 @@ impl<'a> Inspector<'a> {
|
||||
continue;
|
||||
}
|
||||
if let Object::Reference(id) = a
|
||||
&& let Some(root) = self.unlisted_field(*id, &field_seen)
|
||||
&& let Some(root) = self.unlisted_field(*id, &walk.seen)
|
||||
{
|
||||
self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0);
|
||||
self.field(&Object::Reference(root), None, &mut info.fields, &mut walk, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,3 +456,40 @@ mod open_action_javascript_tests {
|
||||
assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unlinked_update_tests {
|
||||
use super::*;
|
||||
|
||||
/// An update without `/Prev` cuts the page tree off the chain; the pages come from the
|
||||
/// rebuilt table, the page tree's pages in its order, and the file says it was rebuilt.
|
||||
#[test]
|
||||
fn an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt() {
|
||||
let info = inspect(Arc::new(pdfcraft_testkit::update_without_prev(false)), None).expect("the unlinked file opens");
|
||||
assert_eq!(info.pages.len(), 3, "{:?}", info.pages);
|
||||
assert_eq!(info.pages.iter().map(|p| p.width).collect::<Vec<_>>(), [200.0, 300.0, 400.0]);
|
||||
assert_eq!(info.warnings, [UNLINKED_UPDATE]);
|
||||
// The structure is read from the rebuilt table as well: the second page's link.
|
||||
assert_eq!(info.links.len(), 1, "{:?}", info.links);
|
||||
let linked = inspect(Arc::new(pdfcraft_testkit::update_without_prev(true)), None).expect("the linked file opens");
|
||||
assert_eq!(linked.pages.len(), 3);
|
||||
assert!(linked.warnings.is_empty(), "{:?}", linked.warnings);
|
||||
assert_eq!(linked.links.len(), 1);
|
||||
}
|
||||
|
||||
/// A hybrid update's `/XRefStm` stream is part of that update, not an earlier section: without
|
||||
/// `/Prev` the file is rebuilt as the document model rebuilds it, and with it, it isn't.
|
||||
#[test]
|
||||
fn a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it() {
|
||||
let bytes = Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false));
|
||||
let info = inspect(bytes.clone(), None).expect("the unlinked hybrid file opens");
|
||||
assert_eq!(info.pages.iter().map(|p| p.width).collect::<Vec<_>>(), [200.0, 300.0, 400.0]);
|
||||
assert_eq!(info.warnings, [UNLINKED_UPDATE]);
|
||||
assert!(pdfcraft_cos::Document::open(bytes).expect("cos opens it").rebuilt_unlinked_update());
|
||||
let bytes = Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true));
|
||||
let linked = inspect(bytes.clone(), None).expect("the linked hybrid file opens");
|
||||
assert_eq!(linked.pages.len(), 3);
|
||||
assert!(linked.warnings.is_empty(), "{:?}", linked.warnings);
|
||||
assert!(!pdfcraft_cos::Document::open(bytes).expect("cos opens it").rebuilt_unlinked_update());
|
||||
}
|
||||
}
|
||||
@@ -287,6 +287,11 @@ pub struct OutlineItem {
|
||||
pub page: Option<usize>,
|
||||
/// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page).
|
||||
pub view: DestView,
|
||||
/// What the bookmark does instead when its action (`/A`, ISO 32000-2 §12.3.3 Table 151) goes
|
||||
/// nowhere in this document: a web address, another PDF, a layer change, or an action named
|
||||
/// but not run ([`LinkTarget::Other`]), read as a link's is. Never [`LinkTarget::Page`]: a
|
||||
/// go-to is `page` and `view`.
|
||||
pub action: Option<LinkTarget>,
|
||||
pub children: Vec<OutlineItem>,
|
||||
/// Whether the bookmark starts open: only a positive `/Count` says so (ISO 32000-2 §12.3.3,
|
||||
/// Table 151). A negative one is closed, and so is a missing one, which a bookmark with
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#[cfg(test)]
|
||||
use super::core::inspect;
|
||||
use super::core::spend;
|
||||
use super::model::{DestView, OutlineItem, PageInfo};
|
||||
use super::model::{DestView, LinkTarget, OutlineItem, PageInfo};
|
||||
use super::{
|
||||
BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK,
|
||||
MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman,
|
||||
@@ -185,8 +185,9 @@ impl<'a> Inspector<'a> {
|
||||
|
||||
/// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order,
|
||||
/// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and
|
||||
/// each bookmark is read once, so the work and memory stay within the window.
|
||||
pub(super) fn outline_window(&self, first: &'a Object) -> (Vec<OutlineItem>, bool) {
|
||||
/// each bookmark is read once, so the work and memory stay within the window. Web addresses are
|
||||
/// spent from `text_budget`, as links' are.
|
||||
pub(super) fn outline_window(&self, first: &'a Object, text_budget: &mut usize) -> (Vec<OutlineItem>, bool) {
|
||||
let mut seen = HashSet::new();
|
||||
// The listed bookmarks in outline order, with their depth.
|
||||
let mut listed: Vec<(usize, OutlineItem)> = Vec::new();
|
||||
@@ -210,12 +211,20 @@ impl<'a> Inspector<'a> {
|
||||
break;
|
||||
}
|
||||
*slot = d.get(b"Next").ok();
|
||||
let dest = d
|
||||
.get(b"Dest")
|
||||
.ok()
|
||||
.and_then(|dest| self.dest(dest, 0))
|
||||
.or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0)));
|
||||
let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v));
|
||||
// A bookmark goes to its `/Dest`, else does what its action `/A` does, as a link would.
|
||||
// Only a go-to's `/D` is a page here: a remote go-to's names a page of the other file.
|
||||
// An action without a type is still read as the go-to its `/D` suggests.
|
||||
let action = match d.get(b"Dest").ok().and_then(|dest| self.dest(dest, 0)) {
|
||||
Some((page, view)) => Some(LinkTarget::Page(page, view)),
|
||||
None => d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| match a.get(b"S") {
|
||||
Ok(_) => self.action_target(a, text_budget),
|
||||
Err(_) => a.get(b"D").ok().and_then(|dest| self.dest(dest, 0)).map(|(page, view)| LinkTarget::Page(page, view)),
|
||||
}),
|
||||
};
|
||||
let ((page, view), action) = match action {
|
||||
Some(LinkTarget::Page(page, view)) => ((Some(page), view), None),
|
||||
other => ((None, DestView::Top), other),
|
||||
};
|
||||
match d.get(b"First").ok() {
|
||||
Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)),
|
||||
Some(_) => more = true,
|
||||
@@ -229,7 +238,7 @@ impl<'a> Inspector<'a> {
|
||||
};
|
||||
let flags = d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0);
|
||||
let (italic, bold) = (flags & 1 != 0, flags & 2 != 0);
|
||||
listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open, color, italic, bold }));
|
||||
listed.push((depth, OutlineItem { title, page, view, action, children: Vec::new(), open, color, italic, bold }));
|
||||
}
|
||||
(nest_outline(listed), more)
|
||||
}
|
||||
|
||||
@@ -231,9 +231,15 @@ impl<'a> Inspector<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs.
|
||||
/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. A
|
||||
/// document opened without the password its embedded files need (`/AuthEvent /EFOpen`, ISO
|
||||
/// 32000-2 §7.6.6) holds them encrypted: asking for one is an error, never its encrypted bytes.
|
||||
pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result<Vec<u8>, String> {
|
||||
let run = || -> Result<Vec<u8>, String> {
|
||||
// Read through lopdf, which can't read a document opened without its key at all.
|
||||
if pdfcraft_cos::Document::open_with_password(std::sync::Arc::new(bytes.to_vec()), password).is_ok_and(|d| d.embedded_files_locked()) {
|
||||
return Err("the attachment is encrypted: open the document with its password to read it".into());
|
||||
}
|
||||
let options = load_options(password);
|
||||
let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?;
|
||||
let insp = Inspector::new(&doc);
|
||||
|
||||
@@ -729,6 +729,31 @@ const CUSTOM_INFO: &[u8] = b"%PDF-1.7
|
||||
trailer << /Root 1 0 R /Info 4 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// One page whose `/Info` is `info` (the dictionary's contents).
|
||||
fn with_info(info: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
4 0 obj << {info} >> endobj
|
||||
trailer << /Root 1 0 R /Info 4 0 R >>
|
||||
%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_and_modification_dates_are_read_for_document_properties() {
|
||||
let info = inspect(Arc::new(with_info("/CreationDate (D:20240105093000+01'00') /ModDate (D:20261031174500Z)")), None).expect("opens");
|
||||
assert_eq!((info.created.as_deref(), info.modified.as_deref()), (Some("2024-01-05 09:30"), Some("2026-10-31 17:45")));
|
||||
// Not a PDF date: shown as written. Empty, a number, a name and absent: no date.
|
||||
let info = inspect(Arc::new(with_info("/CreationDate (last Tuesday) /ModDate ()")), None).expect("opens");
|
||||
assert_eq!((info.created.as_deref(), info.modified.as_deref()), (Some("last Tuesday"), None));
|
||||
let info = inspect(Arc::new(with_info("/CreationDate 20240105 /ModDate /D:20240105093000Z")), None).expect("opens");
|
||||
assert_eq!((info.created, info.modified), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_document_properties_are_listed_apart_from_the_standard_ones() {
|
||||
let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens");
|
||||
@@ -786,6 +811,26 @@ fn fields_listed_only_on_the_pages_are_listed() {
|
||||
assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields);
|
||||
}
|
||||
|
||||
/// A widget whose `/Parent` names an object the file doesn't hold, or one that isn't a dictionary,
|
||||
/// is listed as a field of its own: that parent reads as `null`, the same
|
||||
/// as none (ISO 32000-2 §7.3.9).
|
||||
#[test]
|
||||
fn a_widget_whose_parent_is_missing_is_listed() {
|
||||
let pdf = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >> endobj
|
||||
4 0 obj << /Fields [] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Parent 40 0 R /Rect [10 100 90 120] >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Parent 7 0 R /Rect [10 60 90 80] >> endobj
|
||||
7 0 obj [1 2 3] endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
|
||||
let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect();
|
||||
assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields);
|
||||
}
|
||||
|
||||
/// A `Structure` that counts the objects read through it.
|
||||
struct CountingReads<'a> {
|
||||
doc: &'a Document,
|
||||
@@ -805,6 +850,50 @@ impl Structure for CountingReads<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A font (or any dictionary whose `/Type` is present and isn't `/Annot`) a page lists in `/Annots`
|
||||
/// isn't an annotation (ISO 32000-2 §12.5.2, Table 166) and isn't listed as a comment; the real ones
|
||||
/// around it keep their place in the array, and an entry without `/Type` is still read as one. A
|
||||
/// `/Type` that isn't a name (`/Type 42`) isn't `/Annot` either, while `/Type null` counts as absent.
|
||||
#[test]
|
||||
fn dictionaries_of_another_type_in_annots_are_not_annotations() {
|
||||
let pdf = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj
|
||||
4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (typed) >> endobj
|
||||
6 0 obj << /Type /Font /Subtype /TrueType /BaseFont /Arial >> endobj
|
||||
7 0 obj << /Subtype /Square /Rect [40 40 60 60] /Contents (untyped) >> endobj
|
||||
8 0 obj << /Type 42 /Subtype /Text /Rect [70 70 90 90] /Contents (number type) >> endobj
|
||||
9 0 obj << /Type null /Subtype /Circle /Rect [100 100 120 120] /Contents (null type) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
|
||||
let mut listed: Vec<_> = info.annotations.iter().map(|a| (a.subtype.as_str(), a.index, a.contents.as_deref())).collect();
|
||||
listed.sort_by_key(|a| a.1);
|
||||
assert_eq!(listed, [("Text", 1, Some("typed")), ("Square", 3, Some("untyped")), ("Circle", 5, Some("null type"))], "{:?}", info.annotations);
|
||||
}
|
||||
|
||||
/// `/QuadPoints` is read by the rule redaction apply uses (`pdfcraft_geom::quad_points`): a quad
|
||||
/// with a non-number is left out rather than drawn with a zero, an indirect number is followed,
|
||||
/// and a trailing partial quad is dropped.
|
||||
#[test]
|
||||
fn only_usable_quads_are_listed() {
|
||||
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >> endobj\n\
|
||||
4 0 obj 40 endobj\n\
|
||||
5 0 obj << /Type /Annot /Subtype /Redact /Rect [20 95 40 110] /QuadPoints \
|
||||
[20 /oops 40 110 20 95 40 95 20 110 4 0 R 110 20 95 40 95 20 110 40 110] >> endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF";
|
||||
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
|
||||
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
|
||||
let mut info = DocInfo { pages: vec![page], ..Default::default() };
|
||||
Inspector::new(&doc).fill(&mut info);
|
||||
let mark = info.annotations.iter().find(|a| a.subtype == "Redact").expect("listed");
|
||||
assert_eq!(mark.quads, vec![[20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0]]);
|
||||
}
|
||||
|
||||
/// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many
|
||||
/// small comments sharing one large `/Contents` string stop at the retained-bytes budget.
|
||||
#[test]
|
||||
@@ -857,6 +946,40 @@ fn an_annotation_reached_through_a_reference_chain_is_listed_once() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Comments list their `/C` as pdfcraft-annot draws and lists it: four entries with a null are
|
||||
/// CMYK with the null as 0, one entry is grey, a reference among four entries is unreadable,
|
||||
/// three numbers (some by reference) are RGB, and a text box keeps its three-number `/C`.
|
||||
#[test]
|
||||
fn comment_colours_are_listed_as_drawn() {
|
||||
let pdf = "%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Square /Rect [0 0 10 10] /C [1 0 0 null] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Square /Rect [0 20 10 30] /C 10 0 R >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Square /Rect [0 40 10 50] /C [1 0 0 11 0 R] >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Square /Rect [0 60 10 70] /C [11 0 R 0 0] >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Circle /Rect [0 80 10 90] /C [0 0 0 1] >> endobj
|
||||
9 0 obj << /Type /Annot /Subtype /FreeText /Rect [0 100 10 110] /C [0 1 0] >> endobj
|
||||
10 0 obj [0.5] endobj
|
||||
11 0 obj 1 endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads");
|
||||
let mut saved = Vec::new();
|
||||
doc.save_to(&mut saved).expect("saves");
|
||||
let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens");
|
||||
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
|
||||
for reader in [&doc as &dyn Structure, &lazy] {
|
||||
let mut info = DocInfo { pages: vec![page.clone()], ..Default::default() };
|
||||
Inspector::new(reader).fill(&mut info);
|
||||
let mut listed: Vec<(f32, Option<[f32; 3]>)> = info.annotations.iter().map(|a| (a.rect[1], a.color)).collect();
|
||||
listed.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let colors: Vec<Option<[f32; 3]>> = listed.into_iter().map(|(_, c)| c).collect();
|
||||
assert_eq!(colors, [Some([0.0, 1.0, 1.0]), Some([0.5, 0.5, 0.5]), None, Some([1.0, 0.0, 0.0]), Some([0.0, 0.0, 0.0]), Some([0.0, 1.0, 0.0])]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply finds its comment where the listing keeps it: on the first page of a page-shared
|
||||
/// `/Annots` array, whether its `/IRT` names the comment or an object holding a reference to it.
|
||||
#[test]
|
||||
@@ -1617,6 +1740,57 @@ fn outline_items_carry_their_colour_and_style() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A bookmark's action (`/A`, ISO 32000-2 §12.3.3 Table 151) is read as a link's is. Only a
|
||||
/// go-to's `/D` is a page of this document: a remote go-to's names a page or destination of the
|
||||
/// other file (§12.6.4.3), so `[0 /FitH 200]` there is not this file's first page. A web address,
|
||||
/// another PDF and actions PdfCraft doesn't run are kept for the bookmark to run or report. An
|
||||
/// action without a type is still read as the go-to its `/D` suggests.
|
||||
#[test]
|
||||
fn bookmark_actions_are_read_as_links_read_them() {
|
||||
use DestView::*;
|
||||
let actions = [
|
||||
("Go to", "/A << /S /GoTo /D [3 0 R /FitH 300] >>"),
|
||||
("Web", "/A << /S /URI /URI (https://example.org/manual) >>"),
|
||||
("Other file", "/A << /S /GoToR /F (other.pdf) /D [0 /FitH 200] /NewWindow true >>"),
|
||||
("Other file, named", "/A << /S /GoToR /F (other.pdf) /D (sec) >>"),
|
||||
("Launch", "/A << /S /Launch /F (other.pdf) >>"),
|
||||
("Menu item", "/A << /S /Named /N /NextPage >>"),
|
||||
("Script", "/A << /S /JavaScript /JS (app.alert\\(1\\)) >>"),
|
||||
("No type", "/A << /D [3 0 R /Fit] >>"),
|
||||
];
|
||||
let n = actions.len();
|
||||
let items: Vec<String> = actions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, (title, action))| {
|
||||
let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) };
|
||||
let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) };
|
||||
format!("<< /Title ({title}) /Parent 4 0 R{prev}{next} {action} >>")
|
||||
})
|
||||
.collect();
|
||||
let info = inspect(Arc::new(outline_file(&items)), None).expect("opens");
|
||||
let got: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view, o.action.clone())).collect();
|
||||
let remote = |destination, new_window| Some(LinkTarget::RemoteGoTo { launch: false, file: "other.pdf".into(), destination, new_window });
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
("Go to", Some(0), FitH { top: Some(300.0) }, None),
|
||||
("Web", None, Top, Some(LinkTarget::Uri("https://example.org/manual".into()))),
|
||||
("Other file", None, Top, remote(RemoteDestination::Page { page: 0, view: FitH { top: Some(200.0) } }, Some(true))),
|
||||
("Other file, named", None, Top, remote(RemoteDestination::Named("sec".into()), None)),
|
||||
(
|
||||
"Launch",
|
||||
None,
|
||||
Top,
|
||||
Some(LinkTarget::RemoteGoTo { launch: true, file: "other.pdf".into(), destination: RemoteDestination::Default, new_window: None })
|
||||
),
|
||||
("Menu item", None, Top, Some(LinkTarget::Other("Named".into()))),
|
||||
("Script", None, Top, Some(LinkTarget::Other("JavaScript".into()))),
|
||||
("No type", Some(0), Fit, None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_window_keeps_the_first_bookmarks_and_says_when_more_follow() {
|
||||
const COUNT: usize = OUTLINE_WINDOW + 5;
|
||||
@@ -1912,3 +2086,168 @@ fn inspection_keeps_no_private_page_data() {
|
||||
};
|
||||
assert_eq!(report(with), report(without), "what inspection reports does not change");
|
||||
}
|
||||
|
||||
/// A `/Kids` entry that loops back, or names a root field that doesn't name this one as its
|
||||
/// `/Parent`, is not a child (ISO 32000-2 §12.7.3, Table 224; §12.7.4.1, Table 226): the field
|
||||
/// keeps its other kids and is listed with them.
|
||||
#[test]
|
||||
fn kids_that_loop_back_or_are_roots_are_not_children() {
|
||||
let pdf = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R 9 0 R 12 0 R 15 0 R] >> endobj
|
||||
4 0 obj << /Fields [5 0 R 7 0 R 8 0 R 10 0 R] >> endobj
|
||||
5 0 obj << /FT /Btn /Ff 49152 /T (size) /Kids [5 0 R 6 0 R] >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj
|
||||
7 0 obj << /FT /Btn /Ff 49152 /T (left) /Kids [8 0 R 9 0 R] >> endobj
|
||||
8 0 obj << /FT /Btn /Ff 49152 /T (right) /Kids [7 0 R 12 0 R] >> endobj
|
||||
9 0 obj << /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [10 120 20 130] >> endobj
|
||||
10 0 obj << /T (group) /Kids [13 0 R] >> endobj
|
||||
12 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj
|
||||
13 0 obj << /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >> endobj
|
||||
14 0 obj << /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >> endobj
|
||||
15 0 obj << /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [10 60 90 80] >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
|
||||
let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.kind, f.page, f.rect.map(|r| r[1]))).collect();
|
||||
assert_eq!(
|
||||
listed,
|
||||
[
|
||||
("size", FieldKind::Radio, Some(0), Some(150.0)),
|
||||
("left", FieldKind::Radio, Some(0), Some(120.0)),
|
||||
("right", FieldKind::Radio, Some(0), Some(90.0)),
|
||||
("group.inner.deep", FieldKind::Text, Some(0), Some(60.0)),
|
||||
],
|
||||
"{:?}",
|
||||
info.fields
|
||||
);
|
||||
}
|
||||
|
||||
/// A field with no widget among its `/Kids` takes the page widget that names it as its
|
||||
/// `/Parent`: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here `pair` and `other`
|
||||
/// list each other (roots, so not children), and only a page widget says it belongs to `other`.
|
||||
#[test]
|
||||
fn a_field_takes_the_page_widget_naming_it_when_its_kids_list_none() {
|
||||
let pdf = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 6 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 7 0 R 11 0 R 13 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj
|
||||
5 0 obj << /FT /Btn /Ff 49152 /T (radio) /Kids [5 0 R 7 0 R] >> endobj
|
||||
6 0 obj << /Fields [5 0 R 8 0 R 9 0 R] >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 120 20 130] >> endobj
|
||||
8 0 obj << /FT /Btn /Ff 49152 /T (pair) /Kids [9 0 R 11 0 R] >> endobj
|
||||
9 0 obj << /FT /Btn /Ff 49152 /T (other) /Kids [8 0 R] >> endobj
|
||||
11 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj
|
||||
13 0 obj << /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [10 60 20 70] >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
|
||||
let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.page, f.rect.map(|r| r[1]))).collect();
|
||||
assert_eq!(listed, [("radio", Some(0), Some(120.0)), ("pair", Some(0), Some(90.0)), ("other", Some(0), Some(60.0))], "{:?}", info.fields);
|
||||
}
|
||||
|
||||
/// The password values (`/O`, `/U`, `/OE`, `/UE`, `/Perms`) of an AES-256 (R6) protection with a
|
||||
/// user password, as a save writes them.
|
||||
fn r6_password_values() -> String {
|
||||
let plain = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec();
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(plain)).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "user pw",
|
||||
owner_password: "owner pw",
|
||||
permissions: -4,
|
||||
encrypt_metadata: true,
|
||||
seed: [5; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let saved = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
let saved = pdfcraft_cos::Document::open_with_password(Arc::new(saved), Some("user pw")).unwrap();
|
||||
let encrypt = saved.resolve(saved.trailer().get(b"Encrypt").unwrap());
|
||||
let encrypt = encrypt.as_dict().unwrap();
|
||||
["O", "U", "OE", "UE", "Perms"]
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let value = saved.resolve(encrypt.get(k.as_bytes()).unwrap());
|
||||
let hex: String = value.as_string().unwrap().bytes.iter().map(|b| format!("{b:02X}")).collect();
|
||||
format!("/{k} <{hex}>")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// A page with text in the clear and an attachment, `note.txt`, encrypted with `/StdCF` (through
|
||||
/// `/EFF`, or a `/Crypt` filter of its own when `named`). `/StdCF` is authorised by `auth_event`,
|
||||
/// under the handler `version`.
|
||||
fn efopen_pdf_with(auth_event: &str, named: bool, version: &str) -> Vec<u8> {
|
||||
let (eff, crypt) = if named { ("", " /Filter [/Crypt] /DecodeParms [<< /Name /StdCF >>]") } else { (" /EFF /StdCF", "") };
|
||||
let encrypt = format!(
|
||||
"<< /Filter /Standard {version} {} /P -4 /CF << /StdCF << /AuthEvent /{auth_event} /CFM /AESV3 /Length 32 >> >> /StmF /Identity /StrF /Identity{eff} >>",
|
||||
r6_password_values()
|
||||
);
|
||||
let page = "BT /F1 12 Tf 20 100 Td (Visible page) Tj ET";
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 4 0 R] >> >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>".to_string(),
|
||||
"<< /Type /Filespec /F (note.txt) /EF << /F 5 0 R >> >>".to_string(),
|
||||
format!("<< /Type /EmbeddedFile{crypt} /Length 32 >>\nstream\n0123456789abcdef0123456789abcdef\nendstream"),
|
||||
format!("<< /Length {} >>\nstream\n{page}\nendstream", page.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
encrypt,
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(
|
||||
format!("trailer\n<< /Size {} /Root 1 0 R /Encrypt {} 0 R /ID [<00> <00>] >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1, objs.len())
|
||||
.as_bytes(),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn efopen_pdf(auth_event: &str) -> Vec<u8> {
|
||||
efopen_pdf_with(auth_event, false, "/V 5 /R 6")
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §7.6.6, Table 25: a crypt filter whose `/AuthEvent` is `/EFOpen` needs its password
|
||||
/// only when an embedded file is opened. Such a document opens without one, its page reads, and
|
||||
/// its attachment is listed; asking for the attachment's bytes is an error, not its encrypted data.
|
||||
#[test]
|
||||
fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() {
|
||||
let bytes = Arc::new(efopen_pdf("EFOpen"));
|
||||
assert!(check_pages(bytes.clone(), None, 1).is_ok());
|
||||
let info = inspect(bytes.clone(), None).expect("opens without a password");
|
||||
assert_eq!(info.pages.len(), 1);
|
||||
let mut renderer = crate::PageRenderer::new(bytes.clone(), crate::RenderConfig::default());
|
||||
let text = renderer.render(crate::RenderRequest { page: 0, kind: crate::RequestKind::Text, ..Default::default() });
|
||||
assert!(text.text.is_some_and(|page| page.plain_text().contains("Visible page")), "{:?}", text.error);
|
||||
let listed = |name: &str| info.attachments.iter().find(|a| a.name == name).unwrap_or_else(|| panic!("{name} is listed")).clone();
|
||||
let refused = attachment_data(&bytes, None, &listed("note.txt")).expect_err("encrypted bytes aren't the file");
|
||||
assert!(refused.contains("password"), "{refused}");
|
||||
// Authorised when the document opens, the same filter still needs the password.
|
||||
let doc_open = Arc::new(efopen_pdf("DocOpen"));
|
||||
assert!(matches!(inspect(doc_open.clone(), None), Err(OpenError::NeedsPassword)));
|
||||
assert!(matches!(check_pages(doc_open, None, 1), Err(OpenError::NeedsPassword)));
|
||||
}
|
||||
|
||||
/// From the review of the EFOpen change: an attachment naming its own EFOpen crypt filter, with no
|
||||
/// `/EFF`, opens the same way; a handler older than crypt filters (V2) still needs the password.
|
||||
#[test]
|
||||
fn efopen_filters_named_by_the_file_open_and_older_handlers_do_not() {
|
||||
let named = Arc::new(efopen_pdf_with("EFOpen", true, "/V 5 /R 6"));
|
||||
let info = inspect(named.clone(), None).expect("opens without a password");
|
||||
let note = info.attachments.iter().find(|a| a.name == "note.txt").expect("listed").clone();
|
||||
assert!(attachment_data(&named, None, ¬e).is_err());
|
||||
let legacy = Arc::new(efopen_pdf_with("EFOpen", false, "/V 2 /R 3 /Length 128"));
|
||||
assert!(matches!(check_pages(legacy, None, 1), Err(OpenError::NeedsPassword)));
|
||||
}
|
||||
@@ -87,6 +87,10 @@ pub struct RenderConfig {
|
||||
/// `hayro_syntax::MAX_PAGE_JBIG2_WORK`. Those past it aren't drawn, like an image that can't
|
||||
/// be decoded.
|
||||
pub jbig2_work: Option<u64>,
|
||||
/// Layer visibility overrides for print-preview rasters ([`PRINT_TAG`]), when the document
|
||||
/// prints its layers differently from how they are shown (the Print event's auto state, ISO
|
||||
/// 32000-2 §8.11.4.4): the preview shows the page as it prints. `None`: previews use `layers`.
|
||||
pub print_layers: Option<Arc<Vec<(i32, i32, bool)>>>,
|
||||
}
|
||||
|
||||
impl RenderConfig {
|
||||
@@ -355,16 +359,20 @@ fn render_page<'a>(
|
||||
config: &RenderConfig,
|
||||
req: RenderRequest,
|
||||
) -> Result<Output, (String, bool)> {
|
||||
// Print previews share the viewer pool, but must use the output font policy. Keep the
|
||||
// caller's cancellation/warning hooks, layers and annotation settings. A fresh cache is
|
||||
// essential: hayro keys cached fonts by PDF dictionary, not by the font resolver, so the
|
||||
// viewer cache could supply an installed face even after changing the resolver (and a
|
||||
// cached missing font from print preview could blank the viewer on switching back).
|
||||
// Print previews share the viewer pool, but must use the output font policy and the layers
|
||||
// as they print (`print_layers`). Keep the caller's cancellation/warning hooks and annotation
|
||||
// settings. A fresh cache is essential: hayro keys cached fonts by PDF dictionary, not by the
|
||||
// font resolver, so the viewer cache could supply an installed face even after changing the
|
||||
// resolver (and a cached missing font from print preview could blank the viewer on switching
|
||||
// back); nothing drawn with the viewer's layers is reused either.
|
||||
let print_settings;
|
||||
let print_cache;
|
||||
let (settings, cache) = if config.system_fonts && req.tag & PRINT_TAG != 0 {
|
||||
let print = req.tag & PRINT_TAG != 0;
|
||||
let print_layers = config.print_layers.as_ref().filter(|_| print);
|
||||
let (settings, cache) = if (config.system_fonts && print) || print_layers.is_some() {
|
||||
print_settings = InterpreterSettings {
|
||||
font_resolver: RenderConfig { system_fonts: false, ..config.clone() }.settings().font_resolver,
|
||||
ocg_overrides: print_layers.cloned().unwrap_or_else(|| settings.ocg_overrides.clone()),
|
||||
..settings.clone()
|
||||
};
|
||||
print_cache = RenderCache::new();
|
||||
@@ -408,6 +416,7 @@ fn render_page<'a>(
|
||||
y_offset: t.y as f32,
|
||||
bg_color,
|
||||
color_replacement,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
|
||||
@@ -3454,6 +3454,56 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
/// PDFBOX-771: a fax image whose `/Rows` says more rows than its `/Height` (1,084,975,104 on a
|
||||
/// 2,592-column scan) is decoded to its height, as the image is (ISO 32000-2 Table 87): the
|
||||
/// decoder was asked for `/Rows` rows, which the size check refused, so every page drew blank.
|
||||
/// Also through an `/Indexed` colour space, the filter's other output path. Samples past the
|
||||
/// decoded data read as black there, so a second image striped black, white, black, white shows
|
||||
/// that every row up to the last is decoded.
|
||||
#[test]
|
||||
fn group4_fax_with_rows_past_its_height_decodes_to_the_image_height() {
|
||||
const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02];
|
||||
// Each row in horizontal mode: black rows as white 0 + black 8, white rows as white 8 +
|
||||
// black 0 (ITU-T T.6 §2.2.3.3, codes from Table 2/T.6), then EOFB.
|
||||
const STRIPED_8X4: [u8; 12] = [0x26, 0xa2, 0x99, 0x86, 0xe4, 0xd4, 0x53, 0x30, 0xdc, 0x00, 0x40, 0x04];
|
||||
const BLACK: [u8; 4] = [0, 0, 0, 255];
|
||||
const WHITE: [u8; 4] = [255, 255, 255, 255];
|
||||
let images: [(&[u8], [[u8; 4]; 4]); 2] = [(&ALL_BLACK_8X4, [BLACK; 4]), (&STRIPED_8X4, [BLACK, WHITE, BLACK, WHITE])];
|
||||
for ((data, expected), space) in images.iter().flat_map(|i| ["/DeviceGray", "[/Indexed /DeviceRGB 1 <000000FFFFFF>]"].map(|s| (i, s))) {
|
||||
for rows in ["/Rows 5", "/Rows 1000000000", "/Rows 4294967295"] {
|
||||
let content = "q 20 0 0 20 5 5 cm /Im1 Do Q";
|
||||
let mut pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} >> /Length {} >> stream
|
||||
",
|
||||
content.len(),
|
||||
data.len()
|
||||
)
|
||||
.into_bytes();
|
||||
pdf.extend_from_slice(data);
|
||||
pdf.extend_from_slice(
|
||||
b"
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
);
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(page.error.is_none(), "{space} {rows}: {:?}", page.error);
|
||||
for (y, colour) in [17, 22, 27, 32].into_iter().zip(expected) {
|
||||
assert_eq!(&page.rgba[((y * 40 + 15) * 4)..][..4], colour, "{space} {rows}: image row at y {y}");
|
||||
}
|
||||
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 255, 255, 255], "{space} {rows}: outside the image stays white");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() {
|
||||
use hayro_syntax::object::{FromBytes, Object};
|
||||
@@ -4073,6 +4123,23 @@ fn jbig2_image_draws_with(stream: &[u8], space: &str, entries: &str, objects: &[
|
||||
}
|
||||
}
|
||||
|
||||
/// A `JBIG2Decode` image whose data starts with a JBIG2 file header (ISO 14492 D.4) draws, in
|
||||
/// either organization the header declares (hayro-jbig2 patch 17): sequential (flags bit 0 is 1,
|
||||
/// D.1) and random access (bit 0 is 0, D.2: the segment headers, ending with an end of file
|
||||
/// segment, then their data). A header with reserved flag bits set leaves the image out.
|
||||
#[test]
|
||||
fn a_jbig2_image_with_a_file_header_draws() {
|
||||
const ID: [u8; 8] = [0x97, 0x4A, 0x42, 0x32, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
let page = black_jbig2_page();
|
||||
let header = |flags: u8| [&ID[..], &[flags], &1u32.to_be_bytes()].concat();
|
||||
let (page_header, page_data) = page.split_at(11);
|
||||
let end_of_file = [0, 0, 0, 1, 51, 0, 1, 0, 0, 0, 0];
|
||||
assert!(jbig2_image_draws(&page), "without a header");
|
||||
assert!(jbig2_image_draws(&[&header(0x01)[..], &page].concat()), "sequential");
|
||||
assert!(jbig2_image_draws(&[&header(0x00)[..], page_header, &end_of_file, page_data].concat()), "random access");
|
||||
assert!(!jbig2_image_draws(&[&header(0x11)[..], &page].concat()), "reserved flag bits");
|
||||
}
|
||||
|
||||
/// The JBIG2 images of a page share one allowance, down to the step, and the next page starts with
|
||||
/// its own. The images differ by an extension segment: one image decoded again isn't decoded twice.
|
||||
#[test]
|
||||
@@ -5503,6 +5570,182 @@ fn a_font_whose_post_version_has_swapped_halves_is_still_used() {
|
||||
assert_eq!(alternate([0, 0, 0, 2]), alternate([0, 2, 0, 0]), "and also with swapped halves");
|
||||
}
|
||||
|
||||
/// A page with no `/MediaBox`, its own or inherited, is US Letter (612 × 792 pt), as other
|
||||
/// readers draw it and as the page model and inspector already sized it; it was A4 (vendored
|
||||
/// hayro-syntax patch 44; ISO 32000-2 Table 31 makes the box required and gives no default;
|
||||
/// PDFBOX-4001). A `/MediaBox` without area falls back to the same size.
|
||||
#[test]
|
||||
fn a_page_without_a_media_box_is_letter() {
|
||||
let page_pdf = |media: &str| {
|
||||
format!(
|
||||
"%PDF-1.4
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R {media} /Contents 4 0 R >> endobj
|
||||
4 0 obj << /Length 25 >> stream
|
||||
0 0 1 rg 10 10 50 50 re f
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
for media in ["", "/MediaBox [0 0 0 0]"] {
|
||||
let pdf = page_pdf(media);
|
||||
let page = render_first_page(pdf.clone());
|
||||
assert_eq!((page.width, page.height), (612, 792), "rendered size, {media:?}");
|
||||
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
|
||||
assert_eq!((info.pages[0].width, info.pages[0].height), (612.0, 792.0), "inspected size, {media:?}");
|
||||
}
|
||||
// The blue square sits at the bottom left of the Letter page, also when the box has no
|
||||
// area: the page is placed and clipped by the same Letter region it is sized by.
|
||||
for media in ["", "/MediaBox [0 0 0 0]"] {
|
||||
let page = render_first_page(page_pdf(media));
|
||||
assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 792 - 30), [0, 0, 255], "{media:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A page without a `/MediaBox` of its own inherits its `/Pages` parent's (ISO 32000-2 §7.7.3.4)
|
||||
/// before any fallback: rendered and inspected at 300 × 400, not US Letter.
|
||||
#[test]
|
||||
fn a_page_inherits_its_parents_media_box_before_falling_back_to_letter() {
|
||||
let pdf = b"%PDF-1.4
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj
|
||||
4 0 obj << /Length 25 >> stream
|
||||
0 0 1 rg 10 10 50 50 re f
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF"
|
||||
.to_vec();
|
||||
let page = render_first_page(pdf.clone());
|
||||
assert_eq!((page.width, page.height), (300, 400), "rendered size");
|
||||
assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 400 - 30), [0, 0, 255]);
|
||||
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
|
||||
assert_eq!((info.pages[0].width, info.pages[0].height), (300.0, 400.0), "inspected size");
|
||||
}
|
||||
|
||||
/// The same through a `/Pages` node without a box: the page takes its grandparent's `/MediaBox`.
|
||||
#[test]
|
||||
fn a_page_inherits_its_grandparents_media_box() {
|
||||
let pdf = b"%PDF-1.4
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [5 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||||
5 0 obj << /Type /Pages /Parent 2 0 R /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 5 0 R /Contents 4 0 R >> endobj
|
||||
4 0 obj << /Length 25 >> stream
|
||||
0 0 1 rg 10 10 50 50 re f
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF"
|
||||
.to_vec();
|
||||
let page = render_first_page(pdf.clone());
|
||||
assert_eq!((page.width, page.height), (300, 400), "rendered size");
|
||||
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
|
||||
assert_eq!((info.pages[0].width, info.pages[0].height), (300.0, 400.0), "inspected size");
|
||||
}
|
||||
|
||||
/// The nearest ancestor's `/MediaBox` wins: an intermediate `/Pages` node's box over the root's,
|
||||
/// and a box whose origin isn't 0 0 keeps the content where the box puts it.
|
||||
#[test]
|
||||
fn a_page_inherits_its_nearest_ancestors_media_box_with_its_origin() {
|
||||
let pdf = b"%PDF-1.4
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [5 0 R] /Count 1 /MediaBox [0 0 612 792] >> endobj
|
||||
5 0 obj << /Type /Pages /Parent 2 0 R /Kids [3 0 R] /Count 1 /MediaBox [100 100 400 500] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 5 0 R /Contents 4 0 R >> endobj
|
||||
4 0 obj << /Length 27 >> stream
|
||||
0 0 1 rg 110 110 50 50 re f
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF"
|
||||
.to_vec();
|
||||
let page = render_first_page(pdf.clone());
|
||||
assert_eq!((page.width, page.height), (300, 400), "the intermediate box, not the root's");
|
||||
// The square sits 10 pt in from the box's lower-left corner, at (100, 100).
|
||||
assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 400 - 30), [0, 0, 255], "placed by the box's origin");
|
||||
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
|
||||
assert_eq!((info.pages[0].width, info.pages[0].height), (300.0, 400.0), "inspected size");
|
||||
}
|
||||
|
||||
/// A page's own `/MediaBox` wins over its parent's: rendered and inspected at the page's 200 × 100, the content placed by
|
||||
/// the page box's origin.
|
||||
#[test]
|
||||
fn a_pages_own_media_box_wins_over_its_parents() {
|
||||
let pdf = b"%PDF-1.4
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [50 50 250 150] /Contents 4 0 R >> endobj
|
||||
4 0 obj << /Length 25 >> stream
|
||||
0 0 1 rg 60 60 50 50 re f
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF"
|
||||
.to_vec();
|
||||
let page = render_first_page(pdf.clone());
|
||||
assert_eq!((page.width, page.height), (200, 100), "the page's box, not its parent's");
|
||||
// The square sits 10 pt in from the page box's lower-left corner, at (50, 50).
|
||||
assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 100 - 30), [0, 0, 255], "placed by the page box's origin");
|
||||
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
|
||||
assert_eq!((info.pages[0].width, info.pages[0].height), (200.0, 100.0), "inspected size");
|
||||
}
|
||||
|
||||
/// The nearest ancestor's `/MediaBox` is the inherited one even when it has no area: an intermediate `/Pages` node's
|
||||
/// `[0 0 0 0]` gives US Letter, not the grandparent's 300 × 400 (ISO 32000-2 §7.7.3.4).
|
||||
#[test]
|
||||
fn a_nearest_media_box_without_area_gives_letter_not_a_further_ancestors() {
|
||||
let pdf = b"%PDF-1.4
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [5 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||||
5 0 obj << /Type /Pages /Parent 2 0 R /Kids [3 0 R] /Count 1 /MediaBox [0 0 0 0] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 5 0 R /Contents 4 0 R >> endobj
|
||||
4 0 obj << /Length 25 >> stream
|
||||
0 0 1 rg 10 10 50 50 re f
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF"
|
||||
.to_vec();
|
||||
let page = render_first_page(pdf.clone());
|
||||
assert_eq!((page.width, page.height), (612, 792), "rendered size");
|
||||
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
|
||||
assert_eq!((info.pages[0].width, info.pages[0].height), (612.0, 792.0), "inspected size");
|
||||
}
|
||||
|
||||
/// An embedded CMap whose codes are all two bytes long (codespace `<0000> <FFFF>`) and whose
|
||||
/// mappings are written as `bfchar` reads `<2041>` as the one code 0x2041 (ISO 32000-2
|
||||
/// §9.7.6.2: the codespace ranges decide how many bytes form a code). It drew as two
|
||||
/// glyphs: the lone first byte 0x20 matched the two-byte entry `<0020>` (PDFBOX-1282,
|
||||
/// `Characters_Arial.pdf` page 3, codes 0x20AC to 0xFFFC). Vendored hayro-cmap patch 4.
|
||||
#[test]
|
||||
fn a_bfchar_cmap_reads_codes_of_its_codespace_length() {
|
||||
let font = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf");
|
||||
let render = |encoding: &str, text: &str| -> Vec<u8> {
|
||||
let cmap = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap
|
||||
/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> def
|
||||
/CMapName /Test-H def /CMapType 1 def
|
||||
1 begincodespacerange <0000> <FFFF> endcodespacerange
|
||||
3 beginbfchar <0020> <0005> <0041> <0006> <2041> <0024> endbfchar
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end";
|
||||
let resources = format!(
|
||||
"<< /Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /JetBrainsMono-Regular /Encoding {encoding} /DescendantFonts [<< /Type /Font /Subtype /CIDFontType2 /BaseFont /JetBrainsMono-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 600 /CIDToGIDMap /Identity /FontDescriptor << /Type /FontDescriptor /FontName /JetBrainsMono-Regular /Flags 4 /FontBBox [-1000 -300 1000 1000] /ItalicAngle 0 /Ascent 1020 /Descent -300 /CapHeight 730 /StemV 80 /FontFile2 5 0 R >> >>] >> >> >>"
|
||||
);
|
||||
let content = format!("BT /F1 40 Tf 10 20 Td {text} Tj ET");
|
||||
let font_dict = format!("<< /Length {} >>", font.len());
|
||||
let cmap_dict = format!(
|
||||
"<< /Type /CMap /CMapName /Test-H /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /Length {} >>",
|
||||
cmap.len()
|
||||
);
|
||||
let page = render_first_page(image_cache_pdf(&content, &resources, &[(&font_dict, font), (&cmap_dict, cmap)]));
|
||||
page.rgba.to_vec()
|
||||
};
|
||||
let blank = render("/Identity-H", "<>");
|
||||
let reference = render("/Identity-H", "<0024>");
|
||||
assert_ne!(reference, blank, "the reference glyph draws");
|
||||
assert_eq!(render("6 0 R", "<2041>"), reference, "<2041> is one code, CID 0x24");
|
||||
assert_ne!(render("/Identity-H", "<00050006>"), reference, "the two-glyph misreading looks different");
|
||||
}
|
||||
|
||||
/// A page whose content expands far beyond its compressed stream stops at the expansion budget,
|
||||
/// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so.
|
||||
#[test]
|
||||
@@ -5796,6 +6039,92 @@ fn page_contents_and_nested_forms_share_one_budget() {
|
||||
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
|
||||
}
|
||||
|
||||
/// A form XObject that draws itself (PDFBOX-5782, a fuzzed file whose form names a `/Pages`
|
||||
/// dictionary as `/X0`, found the form again in the page's resources and spent 38 s drawing
|
||||
/// itself until the page's nested-paint budget ran out) is drawn once: the drawing inside
|
||||
/// itself is skipped and reported, as other readers draw it. Vendored patch: hayro-interpret
|
||||
/// `Context::enter_form` (89).
|
||||
#[test]
|
||||
fn a_form_that_draws_itself_is_drawn_once() {
|
||||
// Each form paints a 10 x 10 square and draws `/X0` 20 units to its right.
|
||||
let draw = "0 0 1 rg 0 0 10 10 re f q 1 0 0 1 20 0 cm /X0 Do Q";
|
||||
// Two forms that draw each other: blue, then red, then blue again would follow.
|
||||
let other = "1 0 0 rg 0 0 10 10 re f q 1 0 0 1 20 0 cm /X0 Do Q";
|
||||
let other = format!(
|
||||
"6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 40] /Resources << /XObject << /X0 5 0 R >> >> /Length {} >> stream\n{other}\nendstream endobj\n",
|
||||
other.len()
|
||||
);
|
||||
let cases = [
|
||||
("its own resources name it", "/Resources << /XObject << /X0 5 0 R >> >>", ""),
|
||||
("it names a /Pages dictionary and the page's resources name it", "/Resources << /XObject << /X0 2 0 R >> >>", ""),
|
||||
("it has no resources and the page's name it", "", ""),
|
||||
("two forms draw each other", "/Resources << /XObject << /X0 6 0 R >> >>", other.as_str()),
|
||||
];
|
||||
for (what, resources, extra) in cases {
|
||||
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 40] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj\n4 0 obj << /Length 7 >> stream\n/X0 Do\nendstream endobj\n".to_vec();
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 40] {resources} /Length {} >> stream\n{draw}\nendstream endobj\n",
|
||||
draw.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf.extend_from_slice(extra.as_bytes());
|
||||
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
|
||||
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(page.error.is_none(), "{what}: {:?}", page.error);
|
||||
assert_eq!(page.width, 200, "{what}");
|
||||
assert_eq!(rgb_at(&page.rgba, 200, 5, 35), [0, 0, 255], "{what}: the form draws");
|
||||
let second = if extra.is_empty() { [255, 255, 255] } else { [255, 0, 0] };
|
||||
assert_eq!(rgb_at(&page.rgba, 200, 25, 35), second, "{what}: the form one level in");
|
||||
assert_eq!(rgb_at(&page.rgba, 200, 45, 35), [255, 255, 255], "{what}: the form is not drawn inside itself");
|
||||
assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "{what}: the skipped drawing is reported");
|
||||
}
|
||||
}
|
||||
|
||||
/// A form that reaches itself through a tiling pattern it fills with, or a Type 3 glyph it
|
||||
/// shows, is not drawn inside itself either: the pattern cell or the glyph procedure is
|
||||
/// interpreted in a context of its own, which still knows the form is being drawn. The form
|
||||
/// paints a blue square, then fills a square 20 units to its right with the pattern, or shows
|
||||
/// the glyph there, whose content draws the form. Vendored patch: hayro-interpret
|
||||
/// `Context::enter_form` (89).
|
||||
#[test]
|
||||
fn a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once() {
|
||||
let cell = "q 1 0 0 1 20 0 cm /X0 Do Q";
|
||||
let pattern = format!(
|
||||
"7 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 200 40] /XStep 200 /YStep 40 /Resources << /XObject << /X0 5 0 R >> >> /Length {} >> stream\n{cell}\nendstream endobj\n",
|
||||
cell.len()
|
||||
);
|
||||
let glyph = "0 0 d0 /X0 Do";
|
||||
let glyph = format!(
|
||||
"7 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 200 40] /FontMatrix [1 0 0 1 0 0] /CharProcs << /a 8 0 R >> /Encoding << /Type /Encoding /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] /Resources << /XObject << /X0 5 0 R >> >> >> endobj\n8 0 obj << /Length {} >> stream\n{glyph}\nendstream endobj\n",
|
||||
glyph.len()
|
||||
);
|
||||
let cases = [
|
||||
("a tiling pattern", "/Resources << /Pattern << /P0 7 0 R >> >>", "0 0 1 rg 0 0 10 10 re f /Pattern cs /P0 scn 20 0 10 10 re f", pattern),
|
||||
("a Type 3 glyph", "/Resources << /Font << /T3 7 0 R >> >>", "0 0 1 rg 0 0 10 10 re f BT /T3 1 Tf 20 0 Td (a) Tj ET", glyph),
|
||||
];
|
||||
for (what, resources, draw, extra) in cases {
|
||||
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 40] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj\n4 0 obj << /Length 7 >> stream\n/X0 Do\nendstream endobj\n".to_vec();
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 40] {resources} /Length {} >> stream\n{draw}\nendstream endobj\n",
|
||||
draw.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf.extend_from_slice(extra.as_bytes());
|
||||
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
|
||||
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(page.error.is_none(), "{what}: {:?}", page.error);
|
||||
assert_eq!(rgb_at(&page.rgba, 200, 5, 35), [0, 0, 255], "{what}: the form draws");
|
||||
assert_eq!(rgb_at(&page.rgba, 200, 25, 35), [255, 255, 255], "{what}: the form is not drawn inside itself");
|
||||
assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "{what}: the skipped drawing is reported");
|
||||
}
|
||||
}
|
||||
|
||||
/// The page content budget counts what is held at once, not everything decoded: a form
|
||||
/// with 2 MiB of content painted three hundred times (600 MiB in all) draws every time.
|
||||
#[test]
|
||||
@@ -7252,8 +7581,8 @@ fn raw_crop_unit_floor_preserves_nearly_zero_fallback() {
|
||||
let bytes = raw_crop_unit_pdf(unit, 0, [0.0, 0.0, side, side], None);
|
||||
let pdf = Pdf::new(Arc::new(bytes)).expect("existing degenerate-page fallback");
|
||||
let page = pdf.pages().first().expect("one page");
|
||||
// Existing A4 raw fallback remains raw A4, then scales by the page unit.
|
||||
let expected = (595.2755905511812_f64 as f32, 841.8897637795277_f64 as f32);
|
||||
// The raw fallback is US Letter (hayro-syntax patch 44), then scales by the page unit.
|
||||
let expected = (612.0, 792.0);
|
||||
assert_eq!(page.base_dimensions(), expected, "unit={unit}, raw side={side}");
|
||||
assert_eq!(page.render_dimensions(), (expected.0 * unit as f32, expected.1 * unit as f32));
|
||||
}
|
||||
@@ -8196,6 +8525,9 @@ fn decoded_images_serve_every_size_they_are_drawn_at() {
|
||||
// ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48).
|
||||
mod icc_profile_cache;
|
||||
|
||||
// A page past its rasterizer coverage leaves the rest out and says so (vendored vello_cpu patch 1).
|
||||
mod coverage_budget;
|
||||
|
||||
// A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156).
|
||||
mod symbolic_truetype_cmap;
|
||||
|
||||
@@ -8980,9 +9312,14 @@ mod default_colour_spaces;
|
||||
// (vendored hayro-syntax patch 28, hayro-interpret patch 55).
|
||||
mod devicen_inks;
|
||||
|
||||
// #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 35).
|
||||
// #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 35), and
|
||||
// PDFBOX-3932: rows that carry no filter-type byte are read as samples (patch 40).
|
||||
mod png_predicted_images;
|
||||
|
||||
// TIFF-predicted Flate images at 8 and 16 bits are undone byte by byte (vendored hayro-syntax
|
||||
// patch 42).
|
||||
mod tiff_predicted_images;
|
||||
|
||||
// #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro
|
||||
// patch 27).
|
||||
mod isolated_cmyk_groups;
|
||||
@@ -9080,6 +9417,183 @@ fn a_rebuilt_table_finds_an_object_whose_header_names_another_generation() {
|
||||
assert_eq!((page.width, page.height), (200, 100));
|
||||
}
|
||||
|
||||
/// A one-page file: a 200 x 100 page, its offsets right.
|
||||
fn one_page_file() -> Vec<u8> {
|
||||
one_page_file_with(false)
|
||||
}
|
||||
|
||||
/// The same, and with `stale` a 999 x 999 copy of the page after it that the table does not
|
||||
/// name: a file read through its table shows 200 x 100, one rebuilt from object headers 999 x 999.
|
||||
fn one_page_file_with(stale: bool) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for body in
|
||||
["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] >>"]
|
||||
{
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes());
|
||||
}
|
||||
if stale {
|
||||
out.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 999 999] >>\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n");
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// `base` with an incremental update appended: a new 300 x 150 first page whose content stream is
|
||||
/// `padding` bytes long, and the page tree redefined to hold both pages. With `footer` the update
|
||||
/// ends in its trailer, `startxref` and `%%EOF`; without, it stops after its cross-reference rows,
|
||||
/// as a save cut short does.
|
||||
fn with_update(base: &[u8], padding: usize, footer: bool) -> Vec<u8> {
|
||||
let prev = base
|
||||
.windows(9)
|
||||
.rposition(|w| w == b"startxref")
|
||||
.map(|p| String::from_utf8_lossy(&base[p + 9..]).split_whitespace().next().unwrap_or("").to_string())
|
||||
.unwrap();
|
||||
let mut out = base.to_vec();
|
||||
let mut offsets = vec![(4, out.len())];
|
||||
out.extend_from_slice(b"4 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Contents 5 0 R >>\nendobj\n");
|
||||
offsets.push((5, out.len()));
|
||||
out.extend_from_slice(format!("5 0 obj\n<< /Length {padding} >>\nstream\n").as_bytes());
|
||||
out.extend(std::iter::repeat_n(b' ', padding));
|
||||
out.extend_from_slice(b"\nendstream\nendobj\n");
|
||||
offsets.push((2, out.len()));
|
||||
out.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [4 0 R 3 0 R] /Count 2 >>\nendobj\n");
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(b"xref\n0 1\n0000000000 65535 f \n");
|
||||
for (num, o) in offsets {
|
||||
out.extend_from_slice(format!("{num} 1\n{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
if footer {
|
||||
out.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What the renderer shows of `file` (page count and the first page's size) and what the
|
||||
/// document model reads (the page tree's /Count), for comparing the two.
|
||||
fn renderer_and_model_pages(file: Vec<u8>) -> ((usize, (u32, u32)), i64) {
|
||||
let bytes = Arc::new(file);
|
||||
let mut r = PageRenderer::new(bytes.clone(), RenderConfig::default());
|
||||
let count = r.page_count();
|
||||
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
let doc = pdfcraft_cos::Document::open(bytes).unwrap();
|
||||
let catalog = doc.get(doc.root().unwrap());
|
||||
let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap());
|
||||
let model = pages.as_dict().and_then(|d| d.int(b"Count")).unwrap();
|
||||
((count, (page.width, page.height)), model)
|
||||
}
|
||||
|
||||
/// PDFBOX-3934, PDFBOX-3787: the last incremental update was cut off after its cross-reference
|
||||
/// rows, with no trailer, `startxref` or `%%EOF`, so the last `startxref` in the file is the
|
||||
/// older revision's, more than the footer window (4 KiB) from the end, and objects follow it.
|
||||
/// The update's objects are the latest revision (ISO 32000-2 §7.5.6); the document model rebuilds
|
||||
/// the file from object headers and reads them. The renderer read the older table the last
|
||||
/// `startxref` names instead and showed one page where the model has two. It now rebuilds too.
|
||||
#[test]
|
||||
fn an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them() {
|
||||
let file = with_update(&one_page_file(), 5000, false);
|
||||
assert_eq!(model_first_media_box(&file), [0.0, 0.0, 300.0, 150.0], "the model's first page is the update's");
|
||||
let ((count, size), model) = renderer_and_model_pages(file);
|
||||
assert_eq!(model, 2, "the model reads the update");
|
||||
assert_eq!(count, 2, "the renderer shows the update's page tree");
|
||||
assert_eq!(size, (300, 150), "and its new first page");
|
||||
}
|
||||
|
||||
/// The same update with its footer is read through its table as before: renderer and model both
|
||||
/// show its two pages.
|
||||
#[test]
|
||||
fn a_complete_update_reads_as_before() {
|
||||
let ((count, size), model) = renderer_and_model_pages(with_update(&one_page_file(), 5000, true));
|
||||
assert_eq!((count, size, model), (2, (300, 150), 2));
|
||||
}
|
||||
|
||||
/// A file with data after `%%EOF` that holds no object header (a web page glued to a served file)
|
||||
/// is read through its table by the renderer, as before: the file has a stale copy of its page that
|
||||
/// only a rebuild would show. The model, finding no `startxref` in its footer window, rebuilds from
|
||||
/// object headers and takes that stale 999 x 999 copy, so here the two still differ, as they did
|
||||
/// before this change.
|
||||
#[test]
|
||||
fn trailing_data_without_objects_reads_as_before() {
|
||||
let mut junk = one_page_file_with(true);
|
||||
junk.extend_from_slice(b"<html><p>1 0 objection: 12 0 endobj, 3 0obj, 4 0 obj_name, 4294967296 0 obj</p>");
|
||||
junk.extend(std::iter::repeat_n(b' ', 5000));
|
||||
junk.extend_from_slice(b"</html>\n");
|
||||
assert_eq!(model_first_media_box(&junk), [0.0, 0.0, 999.0, 999.0], "the model's rebuilt page");
|
||||
let ((count, size), model) = renderer_and_model_pages(junk);
|
||||
assert_eq!((count, size, model), (1, (200, 100), 1));
|
||||
}
|
||||
|
||||
/// The /MediaBox of the first page in the document model's page tree (its first /Kids entry).
|
||||
fn model_first_media_box(file: &[u8]) -> Vec<f64> {
|
||||
let doc = pdfcraft_cos::Document::open(Arc::new(file.to_vec())).unwrap();
|
||||
let catalog = doc.get(doc.root().unwrap());
|
||||
let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap());
|
||||
let kid = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap().first().unwrap());
|
||||
kid.as_dict().unwrap().get(b"MediaBox").unwrap().as_array().unwrap().iter().filter_map(|x| x.as_f64()).collect()
|
||||
}
|
||||
|
||||
/// A cut-off update on `one_page_file` whose object headers are written `N G obj{after}<<` in place
|
||||
/// of `N G obj\n<<`. Only the update is rewritten, so the base file's table stays at the offset
|
||||
/// its `startxref` names and reading it raises no error that would rebuild the file anyway: the
|
||||
/// update is found only if the header scan accepts what follows `obj`.
|
||||
fn cut_off_update_with_headers(after: &str) -> Vec<u8> {
|
||||
let base = one_page_file();
|
||||
let mut file = with_update(&base, 5000, false);
|
||||
let update = String::from_utf8(file.split_off(base.len())).unwrap();
|
||||
assert_eq!(update.matches(" obj\n<<").count(), 3, "every updated header is rewritten");
|
||||
file.extend_from_slice(update.replace(" obj\n<<", &format!(" obj{after}<<")).as_bytes());
|
||||
file
|
||||
}
|
||||
|
||||
/// A cut-off update whose object headers run straight into their dictionaries (`4 0 obj<<`) is
|
||||
/// still found: a delimiter ends the keyword (ISO 32000-2 §7.2.3, Table 2), and the renderer
|
||||
/// shows the update's new first page, as the model does.
|
||||
#[test]
|
||||
fn a_cut_off_update_with_obj_followed_by_a_delimiter_is_rebuilt() {
|
||||
let file = cut_off_update_with_headers("");
|
||||
assert_eq!(model_first_media_box(&file), [0.0, 0.0, 300.0, 150.0], "the model's first page is the update's");
|
||||
let ((count, size), model) = renderer_and_model_pages(file);
|
||||
assert_eq!((count, size, model), (2, (300, 150), 2));
|
||||
}
|
||||
|
||||
/// The same with a comment straight after the keyword (`4 0 obj%comment`): `%` is a delimiter
|
||||
/// that starts a comment (ISO 32000-2 §7.2.3 Table 2, §7.2.4), so it ends the keyword too.
|
||||
#[test]
|
||||
fn a_cut_off_update_with_obj_followed_by_a_comment_is_rebuilt() {
|
||||
let file = cut_off_update_with_headers("%comment\n");
|
||||
assert_eq!(model_first_media_box(&file), [0.0, 0.0, 300.0, 150.0], "the model's first page is the update's");
|
||||
let ((count, size), model) = renderer_and_model_pages(file);
|
||||
assert_eq!((count, size, model), (2, (300, 150), 2));
|
||||
}
|
||||
|
||||
/// A cut-off update that ends within the footer window leaves the older `startxref` near the end,
|
||||
/// which the model reads too: both keep the older revision there.
|
||||
#[test]
|
||||
fn a_short_cut_off_update_reads_as_before() {
|
||||
let ((count, size), model) = renderer_and_model_pages(with_update(&one_page_file(), 100, false));
|
||||
assert_eq!((count, size, model), (1, (200, 100), 1));
|
||||
}
|
||||
|
||||
/// A file cut short in the middle of its last update, whose new page tree names a page that is
|
||||
/// not there: the rebuilt table finds no page, so the older table is read and the file still
|
||||
/// opens with the page it had, rather than not at all.
|
||||
#[test]
|
||||
fn an_update_cut_off_before_its_new_pages_keeps_the_older_table() {
|
||||
let file = String::from_utf8(with_update(&one_page_file(), 5000, false)).unwrap().replace("4 0 obj", "9 0 obj").into_bytes();
|
||||
let mut r = PageRenderer::new(Arc::new(file), RenderConfig::default());
|
||||
assert_eq!(r.page_count(), 1);
|
||||
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
assert_eq!((page.width, page.height), (200, 100));
|
||||
}
|
||||
|
||||
/// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting
|
||||
/// but draws no pixels itself.
|
||||
#[test]
|
||||
@@ -9622,3 +10136,170 @@ fn a_stream_with_a_crypt_filter_of_its_own_is_drawn() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data after `%%EOF` that holds an object header, past the footer window, is read the way the
|
||||
/// document model reads it: the model rebuilds the file from object headers and takes the later
|
||||
/// definition of the page, and the renderer shows that same page, so the two still agree.
|
||||
#[test]
|
||||
fn trailing_data_with_an_object_header_reads_as_the_model_reads_it() {
|
||||
let mut f = one_page_file();
|
||||
f.extend_from_slice(
|
||||
b"<html><pre>
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 999 999] >>
|
||||
endobj
|
||||
</pre>",
|
||||
);
|
||||
f.extend(std::iter::repeat_n(b' ', 5000));
|
||||
let doc = pdfcraft_cos::Document::open(Arc::new(f.clone())).unwrap();
|
||||
let page = doc.get(pdfcraft_cos::ObjRef::new(3, 0));
|
||||
let model_box = page.as_dict().and_then(|d| d.get(b"MediaBox").cloned());
|
||||
assert_eq!(model_box, Some(pdfcraft_cos::Object::Array([0, 0, 999, 999].map(pdfcraft_cos::Object::Int).to_vec())), "the model");
|
||||
let ((count, size), model) = renderer_and_model_pages(f);
|
||||
assert_eq!((count, size, model), (1, (999, 999), 1), "the renderer, as the model");
|
||||
}
|
||||
|
||||
/// An encrypted file whose last update was cut off is still refused with a wrong password, and
|
||||
/// opened with the right one.
|
||||
#[test]
|
||||
fn an_encrypted_file_with_a_cut_off_update_still_needs_its_password() {
|
||||
use pdfcraft_cos::{Algorithm, Document, NewEncryption, SaveOptions, write_full};
|
||||
let mut doc = Document::open(Arc::new(one_page_file())).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm: Algorithm::Aes128,
|
||||
user_password: "user",
|
||||
owner_password: "owner",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [7; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let base = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
let file = Arc::new(with_update(&base, 5000, false));
|
||||
let open = |password: &str| {
|
||||
let mut r = PageRenderer::new(file.clone(), RenderConfig { password: Some(Arc::from(password)), ..RenderConfig::default() });
|
||||
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
(r.page_count(), page.error.is_none(), (page.width, page.height))
|
||||
};
|
||||
let (_, drawn, _) = open("wrong");
|
||||
assert!(!drawn, "a wrong password opens nothing");
|
||||
let (count, drawn, _) = open("user");
|
||||
assert!(count >= 1 && drawn, "the right one opens it");
|
||||
}
|
||||
|
||||
/// An encrypted file whose cut-off update ends in a trailer dictionary without `/Encrypt`: the rebuild would take that
|
||||
/// later dictionary and read the encrypted content as plain bytes, so it is not taken. A wrong password is still
|
||||
/// refused, and the right one opens the table the last `startxref` names, its content decrypted and drawn.
|
||||
#[test]
|
||||
fn a_rebuild_whose_trailer_lacks_the_encryption_dictionary_is_not_taken() {
|
||||
use pdfcraft_cos::{Algorithm, Document, NewEncryption, SaveOptions, write_full};
|
||||
let plain = b"%PDF-1.4
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >> endobj
|
||||
4 0 obj << /Length 25 >> stream
|
||||
0 0 1 rg 10 10 50 50 re f
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF"
|
||||
.to_vec();
|
||||
let mut doc = Document::open(Arc::new(plain)).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm: Algorithm::Aes128,
|
||||
user_password: "user",
|
||||
owner_password: "owner",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [7; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let root = doc.root().unwrap();
|
||||
let mut file = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
// The update: one object, past the 4 KiB footer window, and a trailer without `/Encrypt`, cut off before its
|
||||
// `startxref`.
|
||||
file.extend_from_slice(b"900 0 obj\n<< /Length 5000 >>\nstream\n");
|
||||
file.extend(std::iter::repeat_n(b' ', 5000));
|
||||
file.extend_from_slice(format!("\nendstream\nendobj\ntrailer\n<< /Size 901 /Root {} {} R >>\n", root.num, root.generation).as_bytes());
|
||||
let file = Arc::new(file);
|
||||
let open = |password: &str| {
|
||||
let mut r = PageRenderer::new(file.clone(), RenderConfig { password: Some(Arc::from(password)), ..RenderConfig::default() });
|
||||
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
page.error.is_none().then(|| rgb_at(&page.rgba, page.width as usize, 30, page.height as usize - 30))
|
||||
};
|
||||
assert_eq!(open("wrong"), None, "a wrong password opens nothing");
|
||||
assert_eq!(open(""), None, "nor does none");
|
||||
assert_eq!(open("user"), Some([0, 0, 255]), "the right one draws the decrypted content");
|
||||
}
|
||||
|
||||
/// A stream with no `endstream` after it, as a producer that writes `endstrea` after every stream
|
||||
/// leaves it, is the bytes its `/Length` gives: "how many bytes of the PDF file are used for the
|
||||
/// stream's data" (ISO 32000-2 7.3.8.2). Nothing found its end, so the page's content was lost and
|
||||
/// the page drew blank, while other readers draw it. Vendored hayro-syntax patch 36.
|
||||
#[test]
|
||||
fn a_content_stream_without_endstream_is_drawn_to_its_length() {
|
||||
use super::*;
|
||||
let content = "1 0 0 rg 0 0 200 200 re f";
|
||||
let centre = |end: &str| -> [u8; 4] {
|
||||
let page = format!(
|
||||
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\n{end}\nendobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF",
|
||||
content.len()
|
||||
);
|
||||
let page = PageRenderer::new(Arc::new(page.into_bytes()), RenderConfig::default()).render(RenderRequest {
|
||||
page: 0,
|
||||
kind: RequestKind::Pixels,
|
||||
tile: None,
|
||||
scale: 1.0,
|
||||
tag: 0,
|
||||
preview: false,
|
||||
});
|
||||
let start = (100 * page.width as usize + 100) * 4;
|
||||
let mut pixel = [0; 4];
|
||||
pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
|
||||
pixel
|
||||
};
|
||||
assert_eq!(centre("endstream"), [255, 0, 0, 255]);
|
||||
for end in ["endstrea", "", "end"] {
|
||||
assert_eq!(centre(end), [255, 0, 0, 255], "{end:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A ToUnicode CMap with a token the scanner can't read before its mappings (a stray `>` after
|
||||
/// a CMap name that holds one, as in pdf.js `issue11651.pdf`; a CMap name with spaces in it, as
|
||||
/// in `issue7492.pdf`) still maps its codes (vendored hayro-cmap patch 3, hayro-postscript
|
||||
/// patch 2): the scan stopped at that token, so the page's text could not be copied or found.
|
||||
#[test]
|
||||
fn a_to_unicode_cmap_reads_its_mappings_past_a_token_it_cannot_parse() {
|
||||
use hayro::hayro_interpret::hayro_cmap::{BfString, CMap};
|
||||
let body = "1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n2 beginbfchar\n<002B> <0048>\n<0048> <0065>\nendbfchar\n1 beginbfrange\n<0050> <0052> <0061>\nendbfrange\nendcmap\n";
|
||||
let stray = format!(
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap\n/CIDSystemInfo <</Registry (Adobe) /Ordering (UC>) /Supplement 0>> def\n/CMapName /Adobe-Identity-UC> def\n/CMapType 2 def\n{body}"
|
||||
);
|
||||
let spaced = format!(
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap\n/CMapName /-usr-share-fonts-Panton Family-Fontfabric - Panton.otf,000-UTF16 def\n/CMapType 2 def\n{body}"
|
||||
);
|
||||
for (what, data) in [("a stray >", stray), ("a name with spaces", spaced)] {
|
||||
let cmap = CMap::parse(data.as_bytes(), |_| None).unwrap_or_else(|| panic!("{what}: parsed"));
|
||||
assert_eq!(cmap.lookup_bf_string(0x2B), Some(BfString::Char('H')), "{what}");
|
||||
assert_eq!(cmap.lookup_bf_string(0x48), Some(BfString::Char('e')), "{what}");
|
||||
assert_eq!(cmap.lookup_bf_string(0x51), Some(BfString::Char('b')), "{what}: bfrange");
|
||||
}
|
||||
// Unreadable tokens are stepped over a bounded number of times (1,024 per CMap): a long run
|
||||
// of them ends the scan, and the mappings read before it are kept.
|
||||
let flood = format!("{body}{}", "-".repeat(1 << 16));
|
||||
assert!(CMap::parse(flood.as_bytes(), |_| None).is_some_and(|c| c.lookup_bf_string(0x2B).is_some()));
|
||||
// A run past the limit before the mappings ends the scan before them, and a CMap that read
|
||||
// no mappings, like one of unreadable tokens only, reports failure. (A run of `-` with
|
||||
// nothing between them is one token, as in PostScript, so these are spaced.)
|
||||
let flood_first = format!("{}\n{body}", "- ".repeat(1025));
|
||||
assert!(CMap::parse(flood_first.as_bytes(), |_| None).is_none());
|
||||
assert!(CMap::parse("-".repeat(1 << 16).as_bytes(), |_| None).is_none());
|
||||
// Unclosed strings after the mappings: the scan ends and keeps them. (That the first one,
|
||||
// which reads to the end of the data, is not read again for each `(` inside it is
|
||||
// hayro-postscript's `parse_object_or_skip_does_not_read_a_failed_read_again`.)
|
||||
let unclosed = format!("{body}{}{}", "(".repeat(4096), "x".repeat(1 << 16));
|
||||
assert!(CMap::parse(unclosed.as_bytes(), |_| None).is_some_and(|c| c.lookup_bf_string(0x48).is_some()));
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! A page that paints the same content over and over runs out of rasterizer coverage: what is
|
||||
//! past it is left out and said to be, rather than panicking once a strip's alpha index no longer
|
||||
//! fits (PDFBOX-5415's map page drew one form 2^22 times and panicked at 7 GB).
|
||||
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A blue circle, filled.
|
||||
const FILLED_CIRCLE: &str = "0 0 1 rg 100 160 m 133 160 160 133 160 100 c 160 67 133 40 100 40 c 67 40 40 67 40 100 c 40 133 67 160 100 160 c f";
|
||||
|
||||
/// A page whose form `F0` draws `F1` twice, `F1` draws `F2` twice, and so on `levels` deep; the
|
||||
/// last form fills a circle. The page draws `F0` once, so the circle is filled 2^`levels` times.
|
||||
fn doubling_forms(levels: usize) -> Vec<u8> {
|
||||
doubling_forms_then(levels, FILLED_CIRCLE, "")
|
||||
}
|
||||
|
||||
/// [`doubling_forms`] with the last form painting `leaf` instead, and the page painting `after`
|
||||
/// once the forms are done.
|
||||
fn doubling_forms_then(levels: usize, leaf: &str, after: &str) -> Vec<u8> {
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
// Object 4 is the page's content; forms start at 5.
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /F0 5 0 R >> >> >>".into(),
|
||||
String::new(),
|
||||
];
|
||||
let content = format!("/F0 Do {after}");
|
||||
objs[3] = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
|
||||
for level in 0..=levels {
|
||||
let (body, resources) = if level == levels {
|
||||
(leaf.to_string(), String::new())
|
||||
} else {
|
||||
("/F Do /F Do".to_string(), format!("/Resources << /XObject << /F {} 0 R >> >>", 6 + level))
|
||||
};
|
||||
objs.push(format!("<< /Type /XObject /Subtype /Form /BBox [0 0 200 200] {resources} /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||||
}
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o: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
|
||||
}
|
||||
|
||||
/// Renders page 1 with the rasterizer kept to `max_coverage` values, returning its pixels and
|
||||
/// whether it said content was left out.
|
||||
fn render_kept_to(pdf: Vec<u8>, max_coverage: Option<usize>) -> (Vec<u8>, bool) {
|
||||
let parsed = Pdf::new(Arc::new(pdf)).unwrap();
|
||||
let truncated = Arc::new(Mutex::new(false));
|
||||
let seen = truncated.clone();
|
||||
let settings = InterpreterSettings {
|
||||
warning_sink: Arc::new(move |w| {
|
||||
if matches!(w, InterpreterWarning::ContentTruncated) {
|
||||
*seen.lock().unwrap() = true;
|
||||
}
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let pixmap =
|
||||
hayro::render(&parsed.pages()[0], &RenderCache::new(), &settings, &RenderSettings { bg_color: WHITE, max_coverage, ..Default::default() });
|
||||
let cut = *truncated.lock().unwrap();
|
||||
(pixmap.data_as_u8_slice().to_vec(), cut)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_past_its_coverage_leaves_the_rest_out_and_says_so() {
|
||||
// 2^10 circles, each storing its edge tiles' coverage.
|
||||
let pdf = doubling_forms(10);
|
||||
let (whole, whole_cut) = render_kept_to(pdf.clone(), None);
|
||||
assert!(!whole_cut, "within the limit nothing is left out");
|
||||
let blue = |px: &[u8]| px.as_chunks::<4>().0.iter().filter(|p| p[2] > 200 && p[0] < 50).count();
|
||||
assert!(blue(&whole) > 10_000, "the circle is drawn");
|
||||
|
||||
// Room for a few circles only: the rest are left out, the page still renders, and says so.
|
||||
let (kept, cut) = render_kept_to(pdf, Some(200_000));
|
||||
assert!(cut, "the page says content was left out");
|
||||
assert_eq!(kept.len(), whole.len());
|
||||
assert!(blue(&kept) > 10_000, "the circles drawn before the limit stay: {}", blue(&kept));
|
||||
}
|
||||
|
||||
/// Pixels of `px` that are mostly red.
|
||||
fn red(px: &[u8]) -> usize {
|
||||
px.as_chunks::<4>().0.iter().filter(|p| p[0] > 200 && p[2] < 50).count()
|
||||
}
|
||||
|
||||
/// The page paints `leaf` 2^10 times through forms and then `marker` (red, away from the leaf's
|
||||
/// drawing) once, both through the same rasterizer path. Kept to 200,000 coverage values, the
|
||||
/// marker is past the limit: it is left out and the page says so. Unlimited, it is drawn.
|
||||
fn leaves_out_past_the_limit(leaf: &str, marker: &str) {
|
||||
let pdf = doubling_forms_then(10, leaf, marker);
|
||||
let (whole, whole_cut) = render_kept_to(pdf.clone(), None);
|
||||
assert!(!whole_cut, "within the limit nothing is left out");
|
||||
assert!(red(&whole) > 50, "unlimited, the marker is drawn: {}", red(&whole));
|
||||
|
||||
let (kept, cut) = render_kept_to(pdf, Some(200_000));
|
||||
assert!(cut, "the page says content was left out");
|
||||
assert_eq!(kept.len(), whole.len());
|
||||
assert_eq!(red(&kept), 0, "the marker, past the limit, is left out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strokes_past_the_coverage_are_left_out() {
|
||||
// A stroked circle and a stroked curve: neither is a rectangle, so both go through
|
||||
// `stroke_path`, and the page's only coverage is strokes.
|
||||
leaves_out_past_the_limit(
|
||||
"0 0 1 RG 4 w 100 160 m 133 160 160 133 160 100 c 160 67 133 40 100 40 c 67 40 40 67 40 100 c 40 133 67 160 100 160 c S",
|
||||
"1 0 0 RG 6 w 170 10 m 180 30 190 10 195 30 c S",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_rectangles_past_the_coverage_are_left_out() {
|
||||
// Axis-aligned rectangles fill through `fill_rect_fast`, and are the page's only coverage.
|
||||
leaves_out_past_the_limit("0 0 1 rg 40.5 40.5 119 119 re f", "1 0 0 rg 170.5 10.5 20 20 re f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_clips_past_the_coverage_are_left_out() {
|
||||
// hayro pushes no clipped layers (its clips go through `push_clip_path`), so the layer clip
|
||||
// is driven through the vello_cpu context directly, with clips as the scene's only coverage.
|
||||
use hayro::vello_cpu::color::palette::css::RED;
|
||||
use hayro::vello_cpu::kurbo::{Circle, Rect, Shape};
|
||||
use hayro::vello_cpu::{Pixmap, RenderContext, Resources};
|
||||
|
||||
let clip = Circle::new((50.0, 50.0), 40.0).to_path(0.1);
|
||||
let mut ctx = RenderContext::new(100, 100);
|
||||
// Room while fewer than 30,000 - 2 x 100 x 100 = 10,000 values are stored.
|
||||
ctx.set_max_coverage(30_000);
|
||||
assert!(!ctx.coverage_full(), "an empty scene has room");
|
||||
|
||||
// Below the limit the clip is kept: a fill inside it shows within the circle only.
|
||||
ctx.push_clip_layer(&clip);
|
||||
assert!(!ctx.coverage_full(), "the first clip fits");
|
||||
ctx.set_paint(RED);
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
ctx.pop_layer();
|
||||
assert!(!ctx.coverage_full(), "the fill inside it fits too");
|
||||
let mut pixmap = Pixmap::new(100, 100);
|
||||
ctx.flush();
|
||||
ctx.render_to_pixmap(&mut Resources::new(), &mut pixmap);
|
||||
let px = |x: usize, y: usize| pixmap.data_as_u8_slice().get((y * 100 + x) * 4..(y * 100 + x) * 4 + 4).map(<[u8]>::to_vec);
|
||||
assert_eq!(px(50, 50), Some(vec![255, 0, 0, 255]), "inside the clip");
|
||||
assert_eq!(px(2, 2), Some(vec![0, 0, 0, 0]), "outside the clip");
|
||||
|
||||
ctx.reset();
|
||||
for _ in 0..1_000 {
|
||||
ctx.push_clip_layer(&clip);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
assert!(ctx.coverage_full(), "the clips past the limit were left out, and that is said");
|
||||
// The scene still rasterizes: the layers left unclipped have nothing drawn into them.
|
||||
ctx.flush();
|
||||
ctx.render_to_pixmap(&mut Resources::new(), &mut pixmap);
|
||||
|
||||
ctx.reset();
|
||||
assert!(!ctx.coverage_full(), "a reset scene starts with room");
|
||||
}
|
||||
|
||||
/// Past the limit the clip itself is refused, not only what is drawn into its layer: with room
|
||||
/// for exactly one path, a fill takes it and the clip pushed next is what finds the scene full.
|
||||
#[test]
|
||||
fn a_layer_clip_past_the_coverage_is_itself_left_out() {
|
||||
use hayro::vello_cpu::RenderContext;
|
||||
use hayro::vello_cpu::color::palette::css::RED;
|
||||
use hayro::vello_cpu::kurbo::{Circle, Rect, Shape};
|
||||
|
||||
let mut ctx = RenderContext::new(100, 100);
|
||||
// Room while fewer than 20,001 - 2 x 100 x 100 = 1 value is stored: for one path.
|
||||
ctx.set_max_coverage(20_001);
|
||||
ctx.set_paint(RED);
|
||||
ctx.fill_path(&Rect::new(10.5, 10.5, 30.5, 30.5).to_path(0.1));
|
||||
assert!(!ctx.coverage_full(), "the fill fits");
|
||||
ctx.push_clip_layer(&Circle::new((50.0, 50.0), 40.0).to_path(0.1));
|
||||
assert!(ctx.coverage_full(), "the clip, past the limit, is left out and that is said");
|
||||
ctx.pop_layer();
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! #5430: Flate images with PNG predictors (ISO 32000-2 §7.4.4.4) are undone a byte row at a
|
||||
//! time (vendored hayro-syntax patch 35). The bit-level decoder they went through before dropped
|
||||
//! the last byte of each filtered row that ends part-way through a pixel, so every later row
|
||||
//! started a byte early and the picture came out sheared.
|
||||
//! started a byte early and the picture came out sheared. An image whose rows carry no filter-type
|
||||
//! byte at all, although it declares a PNG predictor, is read as its samples (patch 40).
|
||||
|
||||
use super::rgb_at;
|
||||
use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
@@ -67,3 +68,73 @@ fn a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PDFBOX-3932: a writer declared `/Predictor 15` for an image and its soft mask without applying
|
||||
/// it, so their rows carry no PNG filter-type byte (vendored hayro-syntax patch 40). Read with the
|
||||
/// predictor, each row's first sample was taken for a filter type and the image came out a row
|
||||
/// short and sheared; PDFium, MuPDF and Poppler draw the samples as they are.
|
||||
#[test]
|
||||
fn an_image_and_soft_mask_whose_rows_carry_no_png_tags_draw_their_samples() {
|
||||
// Four rows of three 8-bit RGB pixels, every sample above 4 (no byte reads as a filter type
|
||||
// that would happen to undo cleanly), and an opaque mask of three grey samples a row.
|
||||
let rgb: Vec<u8> = ROWS.iter().flat_map(|px| (0..3).flat_map(move |_| px.map(|s| s * 16 + 8))).collect();
|
||||
let mask = [255u8; 12];
|
||||
let pdf = image_pdf(&[("/ColorSpace /DeviceRGB /SMask 6 0 R", "/Colors 3", &rgb), ("/ColorSpace /DeviceGray", "/Colors 1", &mask)]);
|
||||
let page = render(pdf);
|
||||
for (row, px) in ROWS.iter().enumerate() {
|
||||
let expected = px.map(|s| s * 16 + 8);
|
||||
for column in 0..3 {
|
||||
let (x, y) = (column * 50 + 25, row * 50 + 25);
|
||||
assert_eq!(rgb_at(&page.rgba, WIDTH, x, y), expected, "image row {row}, column {column}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data one byte short of rows without tags is neither layout: it is decoded with the predictor
|
||||
/// as before, and does not panic.
|
||||
#[test]
|
||||
fn an_image_one_byte_short_of_untagged_rows_still_goes_through_the_predictor() {
|
||||
let rgb = [0u8; 35];
|
||||
let page = render(image_pdf(&[("/ColorSpace /DeviceRGB", "/Colors 3", &rgb)]));
|
||||
assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32));
|
||||
}
|
||||
|
||||
/// Image objects 5, 6, … of a 3 × 4 8-bit image each, Flate-compressed with `/Predictor 15`
|
||||
/// declared and their `data` stored as given; the page draws object 5 over all of itself.
|
||||
fn image_pdf(images: &[(&str, &str, &[u8])]) -> Vec<u8> {
|
||||
let content = "q 150 0 0 200 0 0 cm /Im0 Do Q";
|
||||
let mut pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 150 200] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n",
|
||||
content.len()
|
||||
)
|
||||
.into_bytes();
|
||||
for (i, (keys, colors, data)) in images.iter().enumerate() {
|
||||
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(data).unwrap();
|
||||
let data = encoder.finish().unwrap();
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"{} 0 obj << /Type /XObject /Subtype /Image /Width 3 /Height 4 {keys} /BitsPerComponent 8 \
|
||||
/Filter /FlateDecode /DecodeParms << /Predictor 15 {colors} /BitsPerComponent 8 /Columns 3 >> /Length {} >> stream\n",
|
||||
5 + i,
|
||||
data.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf.extend_from_slice(&data);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\n");
|
||||
}
|
||||
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
|
||||
pdf
|
||||
}
|
||||
|
||||
fn render(pdf: Vec<u8>) -> crate::RenderedPage {
|
||||
let mut renderer = PageRenderer::new(Arc::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(), "{:?}", page.error);
|
||||
assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32));
|
||||
page
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Flate images with TIFF predictor 2 (ISO 32000-2 §7.4.4.4, Table 10) at 8 and 16 bits per
|
||||
//! component are undone a byte row at a time (vendored hayro-syntax patch 42), with the samples
|
||||
//! the bit-level decoder gave.
|
||||
|
||||
use super::rgb_at;
|
||||
use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
use flate2::{Compression, write::ZlibEncoder};
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The page is 150 × 200 pt, drawn at 72 dpi; a 3 × 4 image fills it.
|
||||
const WIDTH: usize = 150;
|
||||
const HEIGHT: usize = 200;
|
||||
|
||||
/// Renders a 3 × 4 image whose samples are `rows` (each row's bytes, before prediction),
|
||||
/// differenced by TIFF predictor 2 with `pixel_len`-byte pixels of `sample_len`-byte samples.
|
||||
fn render(rows: &[Vec<u8>], colour_space: &str, colors: usize, bpc: usize) -> Vec<u8> {
|
||||
let (sample_len, pixel_len) = (bpc / 8, colors * bpc / 8);
|
||||
let mut predicted = Vec::new();
|
||||
for row in rows {
|
||||
let mut out = row.clone();
|
||||
for i in (pixel_len..row.len()).step_by(sample_len).rev() {
|
||||
if sample_len == 2 {
|
||||
let cur = u16::from_be_bytes([row[i], row[i + 1]]);
|
||||
let left = u16::from_be_bytes([row[i - pixel_len], row[i - pixel_len + 1]]);
|
||||
out[i..i + 2].copy_from_slice(&cur.wrapping_sub(left).to_be_bytes());
|
||||
} else {
|
||||
out[i] = row[i].wrapping_sub(row[i - pixel_len]);
|
||||
}
|
||||
}
|
||||
predicted.extend(out);
|
||||
}
|
||||
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(&predicted).unwrap();
|
||||
let data = encoder.finish().unwrap();
|
||||
|
||||
let content = "q 150 0 0 200 0 0 cm /Im0 Do Q";
|
||||
let mut pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 150 200] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
|
||||
5 0 obj << /Type /XObject /Subtype /Image /Width 3 /Height 4 /ColorSpace /{colour_space} /BitsPerComponent {bpc} \
|
||||
/Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns 3 >> /Length {} >> stream\n",
|
||||
content.len(),
|
||||
data.len()
|
||||
)
|
||||
.into_bytes();
|
||||
pdf.extend_from_slice(&data);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
|
||||
|
||||
let mut renderer = PageRenderer::new(Arc::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(), "{:?}", page.error);
|
||||
assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32));
|
||||
page.rgba.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_eight_bit_rgb_image_with_tiff_predicted_rows_draws_its_colours() {
|
||||
// Each pixel differs from the one to its left, and some differences wrap past 255.
|
||||
let pixels: [[[u8; 3]; 3]; 4] = [
|
||||
[[255, 0, 0], [0, 255, 0], [0, 0, 255]],
|
||||
[[10, 200, 30], [250, 5, 128], [0, 0, 0]],
|
||||
[[255, 255, 255], [1, 2, 3], [128, 64, 32]],
|
||||
[[0, 128, 255], [255, 128, 0], [64, 64, 64]],
|
||||
];
|
||||
let rows: Vec<Vec<u8>> = pixels.iter().map(|row| row.iter().flatten().copied().collect()).collect();
|
||||
let rgba = render(&rows, "DeviceRGB", 3, 8);
|
||||
for (row, colours) in pixels.iter().enumerate() {
|
||||
for (column, rgb) in colours.iter().enumerate() {
|
||||
let (x, y) = (column * 50 + 25, row * 50 + 25);
|
||||
assert_eq!(rgb_at(&rgba, WIDTH, x, y), *rgb, "image row {row}, column {column}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sixteen_bit_grey_image_with_tiff_predicted_rows_draws_its_levels() {
|
||||
// The high byte decides the level drawn; the differences carry across the byte boundary.
|
||||
let levels: [[u16; 3]; 4] = [[0xffff, 0x00ff, 0x8000], [0x0100, 0xff00, 0x4080], [0, 0x7fff, 0xc000], [0x2000, 0x2000, 0xe0ff]];
|
||||
let rows: Vec<Vec<u8>> = levels.iter().map(|row| row.iter().flat_map(|v| v.to_be_bytes()).collect()).collect();
|
||||
let rgba = render(&rows, "DeviceGray", 1, 16);
|
||||
for (row, values) in levels.iter().enumerate() {
|
||||
for (column, value) in values.iter().enumerate() {
|
||||
let (x, y) = (column * 50 + 25, row * 50 + 25);
|
||||
let [r, g, b] = rgb_at(&rgba, WIDTH, x, y);
|
||||
let expected = i32::from(value >> 8);
|
||||
for got in [r, g, b] {
|
||||
assert!((i32::from(got) - expected).abs() <= 1, "image row {row}, column {column}: {got} for {value:#06x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,9 @@ pub(crate) struct LazyStructure {
|
||||
impl LazyStructure {
|
||||
pub(crate) fn new(bytes: Arc<Vec<u8>>, password: Option<&str>) -> std::result::Result<Self, String> {
|
||||
let source = Document::open_with_stream_limit(bytes, password, 256 << 20).map_err(|e| e.to_string())?;
|
||||
if !source.repair_log().is_empty() {
|
||||
// A file rebuilt because its last update has no `/Prev` is read here as rebuilt: the
|
||||
// renderer rebuilds it too, and the fallback reader would follow the cut-off chain.
|
||||
if !source.repair_log().is_empty() && !source.rebuilt_unlinked_update() {
|
||||
return Err("the structure requires compatibility repair".into());
|
||||
}
|
||||
let trailer = convert_dict(source.trailer(), 0).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -23,6 +23,9 @@ pub struct TextFilter {
|
||||
pub code: bool,
|
||||
/// Exclude Figure-marked text and invisible text layers, including OCR.
|
||||
pub images: bool,
|
||||
/// Exclude `/ActualText` that marks no glyph, whose words are drawn as outlines or pictures:
|
||||
/// a page's own text, for deciding whether it still needs reading from its pixels.
|
||||
pub painted: bool,
|
||||
/// Page-local MCIDs whose structure ancestors match the selected exclusions.
|
||||
pub mcids: HashSet<i32>,
|
||||
}
|
||||
@@ -128,7 +131,9 @@ impl PageText {
|
||||
}
|
||||
|
||||
/// Case-insensitive search. Returns glyph ranges of every match (words may span line breaks,
|
||||
/// which count as a single space).
|
||||
/// which count as a single space, or as nothing where the line runs on between two Chinese or
|
||||
/// Japanese characters or ends in a soft hyphen: there a query with a space across the break
|
||||
/// doesn't match, and one without does).
|
||||
pub fn find(&self, needle: &str) -> Vec<std::ops::Range<usize>> {
|
||||
self.find_opts(needle, false, false)
|
||||
}
|
||||
@@ -175,6 +180,11 @@ impl PageText {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Index of the first glyph whose box holds a view-space point: the text under the pointer.
|
||||
pub fn glyph_at(&self, x: f32, y: f32) -> Option<usize> {
|
||||
self.glyphs.iter().position(|g| x >= g.rect[0] && x <= g.rect[2] && y >= g.rect[1] && y <= g.rect[3])
|
||||
}
|
||||
|
||||
/// Index of the glyph nearest to a view-space point (for selection anchors).
|
||||
///
|
||||
/// A glyph whose drawn em box (`quad`) holds the point wins. Skewed or turned text has bounding
|
||||
|
||||
@@ -25,6 +25,16 @@ pub(super) struct TextDevice {
|
||||
/// The outermost open `/ActualText` replacement: its depth, text and the glyphs drawn inside
|
||||
/// it (with no text of their own yet).
|
||||
actual: Option<(usize, String, Vec<Drawn>)>,
|
||||
/// What the open `/ActualText` replacement paints besides glyphs (paths, images), as far as
|
||||
/// it can be seen: where its text reads when it marks no glyph (words drawn as outlines).
|
||||
painted: Option<Painted>,
|
||||
/// Whether the open `/ActualText` replacement paints anything an exclusion leaves out.
|
||||
paint_filtered: bool,
|
||||
/// Whether the open `/ActualText` replacement paints anything wholly outside the crop box.
|
||||
paint_cropped: bool,
|
||||
/// The bounding boxes of the clip paths in force (view space), innermost last, each cut to the
|
||||
/// one before it: an outer bound on what can show.
|
||||
clips: Vec<Rect>,
|
||||
/// Every `/ActualText` replacement made on the page.
|
||||
pub(super) replaced: Vec<Replaced>,
|
||||
/// Visible page bounds in view space (`intersected_crop_box` after `initial_transform`).
|
||||
@@ -105,12 +115,109 @@ fn merge(drawn: &[Drawn], text: String) -> Option<Drawn> {
|
||||
Some((quarter, u, TextGlyph { text, rect, quad, direction: first.2.direction }))
|
||||
}
|
||||
|
||||
/// The widest or tallest box (view-space points) a glyph, or the paint standing in for one, may
|
||||
/// have and still be placed: anything bigger is damage, not text.
|
||||
const MAX_GLYPH_EXTENT: f64 = 10_000.0;
|
||||
|
||||
/// The quarter turn of direction `(dx, dy)` (view space, y down): 0 right, 1 down, 2 left, 3 up.
|
||||
fn quarter_turn(dx: f64, dy: f64) -> usize {
|
||||
if dx.abs() >= dy.abs() {
|
||||
if dx >= 0.0 { 0 } else { 2 }
|
||||
} else if dy > 0.0 {
|
||||
1
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
/// What an `/ActualText` replacement paints besides glyphs, measured as [`merge`] measures
|
||||
/// glyphs: along the writing direction of the first of it (its transform's x axis, `u`) and
|
||||
/// across it.
|
||||
struct Painted {
|
||||
quarter: usize,
|
||||
u: [f64; 2],
|
||||
along: [f64; 2],
|
||||
across: [f64; 2],
|
||||
}
|
||||
|
||||
impl Painted {
|
||||
fn new(transform: Affine) -> Self {
|
||||
let [a, b, ..] = transform.as_coeffs();
|
||||
let length = a.hypot(b);
|
||||
let u = if length.is_finite() && length > 0.0 { [a / length, b / length] } else { [1.0, 0.0] };
|
||||
let empty = [f64::INFINITY, f64::NEG_INFINITY];
|
||||
Painted { quarter: quarter_turn(u[0], u[1]), u, along: empty, across: empty }
|
||||
}
|
||||
|
||||
/// Grow the measure by the view-space box `b`.
|
||||
fn add(&mut self, b: Rect) {
|
||||
let n = [-self.u[1], self.u[0]];
|
||||
for (x, y) in [(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1)] {
|
||||
let (s, t) = (x * self.u[0] + y * self.u[1], x * n[0] + y * n[1]);
|
||||
self.along = [self.along[0].min(s), self.along[1].max(s)];
|
||||
self.across = [self.across[0].min(t), self.across[1].max(t)];
|
||||
}
|
||||
}
|
||||
|
||||
/// A stand-in glyph with no text of its own, boxed around what was painted and read along its
|
||||
/// direction; `None` when that is too big to be the place of words.
|
||||
fn glyph(self) -> Option<Drawn> {
|
||||
let ([s0, s1], [t0, t1]) = (self.along, self.across);
|
||||
// Too big to be the place of words, or NaN.
|
||||
if !(s1 - s0 <= MAX_GLYPH_EXTENT && t1 - t0 <= MAX_GLYPH_EXTENT) {
|
||||
return None;
|
||||
}
|
||||
let (u, n) = (self.u, [-self.u[1], self.u[0]]);
|
||||
let point = |s: f64, t: f64| [(s * u[0] + t * n[0]) as f32, (s * u[1] + t * n[1]) as f32];
|
||||
// The edge towards the next line starts the quad, as a glyph's em box does.
|
||||
let quad = [point(s0, t1), point(s1, t1), point(s1, t0), point(s0, t0)];
|
||||
let rect = quad.iter().fold([f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY], |r, p| {
|
||||
[r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]
|
||||
});
|
||||
rect.iter()
|
||||
.all(|v| v.is_finite())
|
||||
.then(|| (self.quarter, u, TextGlyph { text: String::new(), rect, quad, direction: [u[0] as f32, u[1] as f32] }))
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of `b` inside `c`, or `None` when none of it is. A NaN edge of `c` (a damaged clip)
|
||||
/// leaves that edge uncut.
|
||||
fn cut(b: Rect, c: Rect) -> Option<Rect> {
|
||||
let (x0, y0, x1, y1) = (b.x0.max(c.x0), b.y0.max(c.y0), b.x1.min(c.x1), b.y1.min(c.y1));
|
||||
(x0 <= x1 && y0 <= y1).then(|| Rect::new(x0, y0, x1, y1))
|
||||
}
|
||||
|
||||
/// The view-space box of `path` drawn with `transform`, from its control points (as redaction
|
||||
/// measures a path), or `None` when it has no finite point.
|
||||
fn control_box(path: &BezPath, transform: Affine) -> Option<Rect> {
|
||||
let mut b: Option<Rect> = None;
|
||||
let mut add = |p: kurbo::Point| {
|
||||
let q = transform * p;
|
||||
if q.is_finite() {
|
||||
b = Some(b.map_or_else(|| Rect::from_points(q, q), |r| r.union_pt(q)));
|
||||
}
|
||||
};
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => add(p),
|
||||
kurbo::PathEl::QuadTo(p1, p2) => [p1, p2].into_iter().for_each(&mut add),
|
||||
kurbo::PathEl::CurveTo(p1, p2, p3) => [p1, p2, p3].into_iter().for_each(&mut add),
|
||||
kurbo::PathEl::ClosePath => {}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
impl TextDevice {
|
||||
pub(super) fn new() -> Self {
|
||||
TextDevice {
|
||||
glyphs: Vec::new(),
|
||||
depth: 0,
|
||||
actual: None,
|
||||
painted: None,
|
||||
paint_filtered: false,
|
||||
paint_cropped: false,
|
||||
clips: Vec::new(),
|
||||
replaced: Vec::new(),
|
||||
page: Rect::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY),
|
||||
filter: TextFilter::default(),
|
||||
@@ -128,9 +235,14 @@ impl TextDevice {
|
||||
/// words can't be found.
|
||||
///
|
||||
/// A replacement spanning several lines or writing directions stays per glyph (each glyph's own
|
||||
/// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all.
|
||||
/// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all. One
|
||||
/// that marks no glyph reads where it paints, as far as that can be seen: words drawn as
|
||||
/// outlines or pictures.
|
||||
fn end_actual_text(&mut self) {
|
||||
let Some((_, text, drawn)) = self.actual.take() else { return };
|
||||
let Some((_, text, mut drawn)) = self.actual.take() else { return };
|
||||
let painted = self.painted.take();
|
||||
let paint_filtered = std::mem::take(&mut self.paint_filtered);
|
||||
let paint_cropped = std::mem::take(&mut self.paint_cropped);
|
||||
let cropped = std::mem::take(&mut self.actual_cropped);
|
||||
if std::mem::take(&mut self.actual_filtered) {
|
||||
// ActualText has no mapping from replacement words to individual glyphs. A mixed
|
||||
@@ -138,10 +250,27 @@ impl TextDevice {
|
||||
self.partial_replacement |= !drawn.is_empty();
|
||||
return;
|
||||
}
|
||||
// Before the paint: a span whose glyphs the crop box took has none left, yet it marks
|
||||
// glyphs, so its paint doesn't place it.
|
||||
if cropped {
|
||||
self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control())));
|
||||
return;
|
||||
}
|
||||
if drawn.is_empty()
|
||||
&& !self.filter.painted
|
||||
&& let Some(stand_in) = painted.and_then(Painted::glyph)
|
||||
{
|
||||
// The paint places it, so paint left out mixes it as excluded glyphs would.
|
||||
if paint_filtered {
|
||||
self.partial_replacement = true;
|
||||
return;
|
||||
}
|
||||
// Its words may be among what the crop box hides, as with a glyph cropped away.
|
||||
if paint_cropped {
|
||||
return;
|
||||
}
|
||||
drawn.push(stand_in);
|
||||
}
|
||||
let Some(glyph) = merge(&drawn, text.clone()) else {
|
||||
self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(char::is_control)));
|
||||
return;
|
||||
@@ -156,6 +285,25 @@ impl TextDevice {
|
||||
self.replaced.push(Replaced { at, text, originals });
|
||||
}
|
||||
|
||||
/// Record view-space box `b`, drawn with `transform`, as painted by the open `/ActualText`
|
||||
/// replacement, cut to the visible area (the crop box, and the clip in force); paint wholly
|
||||
/// outside the crop box, or that an exclusion leaves out (`excluded`), only marks the
|
||||
/// replacement.
|
||||
fn paint(&mut self, b: Rect, transform: Affine, excluded: bool) {
|
||||
// Wholly outside the crop box, as a glyph dropped there is: the replacement can't be read.
|
||||
let Some(on_page) = cut(b, self.page) else {
|
||||
self.paint_cropped = true;
|
||||
return;
|
||||
};
|
||||
// Clipped away entirely: nothing of it shows.
|
||||
let Some(seen) = self.clips.last().map_or(Some(on_page), |c| cut(on_page, *c)) else { return };
|
||||
if excluded {
|
||||
self.paint_filtered = true;
|
||||
} else {
|
||||
self.painted.get_or_insert_with(|| Painted::new(transform)).add(seen);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the page. Some producers draw text twice, the copy for an outline, shadow or
|
||||
/// glow, and mark only one drawing with `/ActualText` (Canva). The unmarked redraw of a
|
||||
/// replacement's glyphs (the same text at the same places, in content order) takes the same
|
||||
@@ -259,8 +407,23 @@ fn text_debug() -> bool {
|
||||
impl<'a> Device<'a> for TextDevice {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'a>, _: &PathDrawMode) {
|
||||
// A path is no text, but it places an `/ActualText` replacement that marks no glyph.
|
||||
if self.actual.is_some()
|
||||
&& let Some(b) = control_box(path, transform)
|
||||
{
|
||||
self.paint(b, transform, self.excluded_depth.is_some());
|
||||
}
|
||||
}
|
||||
fn push_clip_path(&mut self, clip: &ClipPath) {
|
||||
// Clip paths arrive in view space. NaN edges (a damaged clip) leave that edge unclipped.
|
||||
let b = clip.path.bounding_box();
|
||||
let visible = match self.clips.last() {
|
||||
Some(c) => Rect::new(c.x0.max(b.x0), c.y0.max(b.y0), c.x1.min(b.x1), c.y1.min(b.y1)),
|
||||
None => b,
|
||||
};
|
||||
self.clips.push(visible);
|
||||
}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, _: &Paint<'a>, mode: &GlyphDrawMode) {
|
||||
if self.excluded_depth.is_some() || (self.filter.images && matches!(mode, GlyphDrawMode::Invisible)) {
|
||||
@@ -309,13 +472,7 @@ impl<'a> Device<'a> for TextDevice {
|
||||
} else {
|
||||
(coefficients[0], coefficients[1])
|
||||
};
|
||||
let dir = if dx.abs() >= dy.abs() {
|
||||
if dx >= 0.0 { 0 } else { 2 }
|
||||
} else if dy > 0.0 {
|
||||
1
|
||||
} else {
|
||||
3
|
||||
};
|
||||
let dir = quarter_turn(dx, dy);
|
||||
let em = Rect::new(0.0, -200.0, advance, 800.0);
|
||||
let corners = if matches!(glyph, Glyph::Outline(g) if g.is_vertical()) {
|
||||
[(em.x0, em.y1), (em.x0, em.y0), (em.x1, em.y0), (em.x1, em.y1)]
|
||||
@@ -328,8 +485,8 @@ impl<'a> Device<'a> for TextDevice {
|
||||
});
|
||||
let outside_crop = b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1);
|
||||
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite())
|
||||
|| b.width() > 10_000.0
|
||||
|| b.height() > 10_000.0
|
||||
|| b.width() > MAX_GLYPH_EXTENT
|
||||
|| b.height() > MAX_GLYPH_EXTENT
|
||||
|| outside_crop
|
||||
{
|
||||
self.actual_cropped |= replaced && outside_crop;
|
||||
@@ -350,8 +507,23 @@ impl<'a> Device<'a> for TextDevice {
|
||||
None => self.glyphs.push((dir, direction, glyph)),
|
||||
}
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
|
||||
// A picture of words, like an outline, places a replacement that marks no glyph; with
|
||||
// images excluded, it is not counted.
|
||||
if self.actual.is_none() {
|
||||
return;
|
||||
}
|
||||
// The transform places the picture's pixels, not a unit square.
|
||||
let (w, h) = (f64::from(image.width()), f64::from(image.height()));
|
||||
let corners = [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].map(|(x, y)| transform * kurbo::Point::new(x, y));
|
||||
let [a, b, c, d] = corners;
|
||||
if corners.iter().all(|p| p.is_finite()) {
|
||||
self.paint(Rect::from_points(a, c).union_pt(b).union_pt(d), transform, self.excluded_depth.is_some() || self.filter.images);
|
||||
}
|
||||
}
|
||||
fn pop_clip_path(&mut self) {
|
||||
self.clips.pop();
|
||||
}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
fn begin_marked_content(&mut self, tag: &[u8], mcid: Option<i32>) {
|
||||
self.depth = self.depth.saturating_add(1);
|
||||
@@ -374,6 +546,11 @@ impl<'a> Device<'a> for TextDevice {
|
||||
}
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
}
|
||||
fn begin_annotations(&mut self) {
|
||||
// A replacement the page content never closed ends with it: the annotations drawn next
|
||||
// are not part of it.
|
||||
self.end_actual_text();
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the text of one page. Panics inside the interpreter are the caller's to catch.
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
//! Literal search in normalized page text, retaining the original glyph owners.
|
||||
//!
|
||||
//! Words and lines are separated by one space, except where a line of Chinese or Japanese runs on
|
||||
//! to the next: a break between two such characters is no word break, so it reads as nothing and
|
||||
//! "検知機構" is found when the line ends after "検知".
|
||||
use super::{PageText, fold_case};
|
||||
use std::ops::Range;
|
||||
|
||||
@@ -7,21 +11,51 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
|
||||
return Vec::new();
|
||||
}
|
||||
let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) };
|
||||
let needle: Vec<char> = fold(query).split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
|
||||
let query: String = fold(query).chars().filter(|&c| !is_invisible(c)).collect();
|
||||
let needle: Vec<char> = query.split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
|
||||
if needle.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut chars: Vec<(char, usize)> = Vec::new();
|
||||
// Whether a skipped ZERO WIDTH SPACE came right before each character: it draws nothing, but
|
||||
// it is a word boundary (UAX #29 breaks on both sides of it), which Thai and Khmer write between words
|
||||
// with no visible gap, so whole-word search still sees two words there.
|
||||
let mut zero_width_space: Vec<bool> = Vec::new();
|
||||
let mut pending_zero_width_space = false;
|
||||
// The last character pushed other than a variation selector, kept as characters are pushed so
|
||||
// a long run of selectors costs no rescan.
|
||||
let mut last_base: Option<char> = None;
|
||||
for (i, glyph) in page.glyphs.iter().enumerate() {
|
||||
let text = fold(&glyph.text);
|
||||
let line_break = i.checked_sub(1).and_then(|previous| page.line_of.get(previous)).zip(page.line_of.get(i)).is_some_and(|(a, b)| a != b);
|
||||
let gap = page.space_before.get(i).copied().unwrap_or(false) || line_break;
|
||||
// A soft hyphen marks where a word may be divided at a line end (ISO 32000-2 §14.8.2.3), so
|
||||
// a line that ends in one runs on to the next with no word break between them.
|
||||
// Other invisible characters after the soft hyphen don't hide it.
|
||||
let soft_break = line_break && last_kept(page, i) == Some('\u{AD}');
|
||||
// So does a line of Chinese or Japanese that runs on to the next. The characters on both
|
||||
// sides of the break are the visible ones: invisible characters are skipped below, and a
|
||||
// variation selector after the last one only picks its glyph.
|
||||
let joined = line_break && last_base.is_some_and(unspaced) && first_visible(page, i).is_some_and(unspaced) && wraps(page, i);
|
||||
let gap = page.space_before.get(i).copied().unwrap_or(false) || (line_break && !(joined || soft_break));
|
||||
if i > 0 && gap && chars.last().is_some_and(|c| c.0 != ' ') {
|
||||
chars.push((' ', i));
|
||||
last_base = Some(' ');
|
||||
zero_width_space.push(false);
|
||||
pending_zero_width_space = false;
|
||||
}
|
||||
for c in fold(&glyph.text).chars() {
|
||||
for c in text.chars() {
|
||||
if is_invisible(c) {
|
||||
pending_zero_width_space |= c == '\u{200B}';
|
||||
continue;
|
||||
}
|
||||
let c = if c.is_whitespace() { ' ' } else { c };
|
||||
if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') {
|
||||
chars.push((c, i));
|
||||
if !is_variation_selector(c) {
|
||||
last_base = Some(c);
|
||||
}
|
||||
zero_width_space.push(pending_zero_width_space && c != ' ');
|
||||
pending_zero_width_space = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +82,9 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
|
||||
}
|
||||
let end = i.saturating_add(1);
|
||||
let start = end.saturating_sub(needle.len());
|
||||
let bounded = !whole_words || (!word(start.checked_sub(1)) && !word(Some(end)));
|
||||
// A skipped zero width space before the first character or after the last is a boundary.
|
||||
let after_break = |i: usize| zero_width_space.get(i).copied().unwrap_or(false);
|
||||
let bounded = !whole_words || ((after_break(start) || !word(start.checked_sub(1))) && (after_break(end) || !word(Some(end))));
|
||||
if bounded {
|
||||
if let Some((_, first)) = chars.get(start)
|
||||
&& let Some(last) = owner.checked_add(1)
|
||||
@@ -68,6 +104,100 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
|
||||
hits
|
||||
}
|
||||
|
||||
/// A character that draws nothing inside a word and only tells layout where a line may break or
|
||||
/// letters may join: SOFT HYPHEN, ZERO WIDTH SPACE, ZERO WIDTH NON-JOINER and JOINER, WORD JOINER
|
||||
/// and ZERO WIDTH NO-BREAK SPACE. All six are Unicode default-ignorable format characters, so
|
||||
/// find skips them in the page text and in the query alike: "Mitarbeiterinnen" matches a word
|
||||
/// stored with soft hyphens. A visible word gap is its own space in the page text, so skipping a
|
||||
/// zero width space next to one still keeps the two words apart.
|
||||
fn is_invisible(c: char) -> bool {
|
||||
matches!(c, '\u{00AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}')
|
||||
}
|
||||
|
||||
/// A variation selector (VS1-VS16 and VS17-VS256): it picks a variant glyph of the character
|
||||
/// before it and is no character of its own.
|
||||
fn is_variation_selector(c: char) -> bool {
|
||||
matches!(c as u32, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF)
|
||||
}
|
||||
|
||||
/// The last character before glyph `i` on the line that glyph `i - 1` ends, other than the
|
||||
/// invisible characters find skips; a soft hyphen counts, since it marks a divided word. Looks
|
||||
/// back no further than that line's start, so each line is read once.
|
||||
fn last_kept(page: &PageText, i: usize) -> Option<char> {
|
||||
let end = i.checked_sub(1)?;
|
||||
let line = page.line_of.get(end)?;
|
||||
(0..=end)
|
||||
.rev()
|
||||
.take_while(|&k| page.line_of.get(k) == Some(line))
|
||||
.filter_map(|k| page.glyphs.get(k))
|
||||
.flat_map(|g| g.text.chars().rev())
|
||||
.find(|&c| c == '\u{AD}' || !is_invisible(c))
|
||||
}
|
||||
|
||||
/// Whether `c` is set solid, with no spaces between words, in text that a line break may divide
|
||||
/// anywhere: East Asian Width Wide, Fullwidth or Halfwidth (Unicode Standard Annex #11) Han,
|
||||
/// kana, bopomofo, CJK symbols and punctuation and fullwidth forms other than Latin letters and
|
||||
/// digits. Hangul is left out: Korean separates its words with spaces, so a line end there is a
|
||||
/// word break. A line break between two such characters is the CSS segment-break rule's case for removing the break, not turning
|
||||
/// it into a space (CSS Text Module, segment break transformation).
|
||||
fn unspaced(c: char) -> bool {
|
||||
matches!(c as u32,
|
||||
0x2E80..=0x2FFF // CJK radicals, Kangxi radicals, ideographic description characters
|
||||
| 0x3001..=0x303E // CJK symbols and punctuation (U+3000 is a space; U+303F is narrow)
|
||||
| 0x3041..=0x30FF // Hiragana, Katakana
|
||||
| 0x3105..=0x312F // Bopomofo
|
||||
| 0x3190..=0x31FF // Kanbun, Bopomofo extended, CJK strokes, Katakana phonetic extensions
|
||||
| 0x3200..=0x4DBF // Enclosed CJK letters, CJK compatibility, Han extension A
|
||||
| 0x4E00..=0x9FFF // Han
|
||||
| 0xF900..=0xFAFF // CJK compatibility ideographs
|
||||
| 0xFE10..=0xFE19 | 0xFE30..=0xFE6F // vertical, compatibility and small forms
|
||||
// Fullwidth forms, less the fullwidth digits and Latin letters (U+FF10-U+FF19, U+FF21-U+FF3A,
|
||||
// U+FF41-U+FF5A): those are Latin text set wide, whose words still end at a line end.
|
||||
| 0xFF01..=0xFF0F | 0xFF1A..=0xFF20 | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF60 | 0xFFE0..=0xFFE6
|
||||
| 0xFF61..=0xFF9F // halfwidth CJK punctuation and Katakana (not halfwidth Hangul)
|
||||
| 0x16FE0..=0x16FFF | 0x1AFF0..=0x1B2FF // ideographic symbols, kana supplements and extensions
|
||||
| 0x20000..=0x3FFFD // Han extensions B and later, compatibility supplement
|
||||
) && !matches!(c as u32, 0x3200..=0x321E | 0x3260..=0x327E) // parenthesised and circled Hangul
|
||||
}
|
||||
|
||||
/// Whether glyph `i` starts the line that the text of glyph `i - 1` runs on to: both run the same
|
||||
/// way, it sits past that glyph across the lines, by more than half the glyph's line height and
|
||||
/// at most three, and starts no further along the line than half a line height past it, as a
|
||||
/// wrapped line does (a line of one character starts level with the one before). Text set side by
|
||||
/// side on one line (table cells, labels), a cell set lower and further along the row, a column
|
||||
/// that starts back at the top, a block further down the page and a flow in another direction
|
||||
/// keep their word break.
|
||||
fn wraps(page: &PageText, i: usize) -> bool {
|
||||
// Measured on the glyphs that own the visible characters either side of the break: an
|
||||
// invisible glyph or a variation selector of its own (drawn with no height) says nothing about
|
||||
// the lines. Each search stays within one line.
|
||||
let Some(end) = i.checked_sub(1) else { return false };
|
||||
let visible = |k: &usize| page.glyphs.get(*k).is_some_and(|g| g.text.chars().any(|c| !is_invisible(c) && !is_variation_selector(c)));
|
||||
let same_line = |k: &usize, of: usize| page.line_of.get(*k).is_some() && page.line_of.get(*k) == page.line_of.get(of);
|
||||
let before = (0..=end).rev().take_while(|k| same_line(k, end)).find(visible).unwrap_or(end);
|
||||
let after = (i..page.glyphs.len()).take_while(|k| same_line(k, i)).find(visible).unwrap_or(i);
|
||||
let Some((before, after)) = page.glyphs.get(before).zip(page.glyphs.get(after)) else { return false };
|
||||
// Unit directions: the same flow has a dot product of 1.
|
||||
let same_flow = before.direction[0] * after.direction[0] + before.direction[1] * after.direction[1] > 0.99;
|
||||
let centre = |q: &[[f32; 2]; 4]| [q.iter().map(|p| p[0]).sum::<f32>() / 4.0, q.iter().map(|p| p[1]).sum::<f32>() / 4.0];
|
||||
let (a, b) = (centre(&before.quad), centre(&after.quad));
|
||||
// Towards the next line: the writing direction turned a quarter clockwise in y-down view space.
|
||||
let [dx, dy] = before.direction;
|
||||
let across = (b[0] - a[0]) * -dy + (b[1] - a[1]) * dx;
|
||||
let along = (b[0] - a[0]) * dx + (b[1] - a[1]) * dy;
|
||||
let [near, far] = [before.quad[0], before.quad[3]];
|
||||
let height = (near[0] - far[0]).hypot(near[1] - far[1]);
|
||||
// NaN compares false: no join.
|
||||
same_flow && across > height * 0.5 && across <= height * 3.0 && along < height * 0.5
|
||||
}
|
||||
|
||||
/// The first visible character on the line that glyph `i` starts, skipping the invisible characters
|
||||
/// that find drops, so a line that opens with a zero width character still joins on its first letter.
|
||||
fn first_visible(page: &PageText, i: usize) -> Option<char> {
|
||||
let line = page.line_of.get(i)?;
|
||||
page.glyphs.iter().zip(&page.line_of).skip(i).take_while(|(_, l)| *l == line).flat_map(|(g, _)| g.text.chars()).find(|&c| !is_invisible(c))
|
||||
}
|
||||
|
||||
/// Extend a prefix, falling back through strictly shorter proper prefixes on a mismatch.
|
||||
fn advance(needle: &[char], prefix: &[usize], mut matched: usize, c: char) -> usize {
|
||||
while matched > 0 && needle.get(matched) != Some(&c) {
|
||||
|
||||
+325
-10
@@ -195,9 +195,10 @@ fn redraws_of_a_long_replacement_share_a_text_budget() {
|
||||
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
|
||||
// a capital "EI". The end-of-data look-ahead used to read the string's bytes as image
|
||||
// data, so the image swallowed the rest of the page.
|
||||
// data, so the image swallowed the rest of the page. The image is filtered, so its
|
||||
// length isn't known up front and the look-ahead decides.
|
||||
let pdf = marked_pages(&[
|
||||
"BI /W 1 /H 1 /BPC 8 /CS /DeviceGray ID \0 EI BT /F1 20 Tf 1 0 0 1 20 200 Tm (Gr\u{fc}\u{df}e) Tj 1 0 0 1 20 150 Tm (EINGANG) Tj ET",
|
||||
"BI /W 1 /H 1 /BPC 8 /CS /DeviceGray /F /AHx ID 00> EI BT /F1 20 Tf 1 0 0 1 20 200 Tm (Gr\u{fc}\u{df}e) Tj 1 0 0 1 20 150 Tm (EINGANG) Tj ET",
|
||||
]);
|
||||
|
||||
assert!(marked_text(&pdf, 0).plain_text().contains("EINGANG"), "the text after the inline image was not extracted");
|
||||
@@ -279,6 +280,207 @@ fn actual_text_follows_the_pdf_rules_for_marked_content() {
|
||||
assert_eq!(plain, ["ofice", "AB", "co op", "x\ny", "afi", "abc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_over_painted_paths_reads_where_they_are_painted() {
|
||||
// Words drawn as outlines (a face that can't be embedded) carry their text in /ActualText
|
||||
// (ISO 32000-2 §14.9.4): it reads at the box of the paths it marks, in view space (y down).
|
||||
let pdf = marked_pages(&[
|
||||
"/Span <</ActualText (valid)>> BDC 100 40 m 160 40 l 160 60 l 100 60 l f EMC",
|
||||
// A curve is measured by its control points, as redaction measures it.
|
||||
"/Span <</ActualText (arc)>> BDC 100 40 m 100 100 160 100 160 40 c f EMC",
|
||||
"BT /F1 20 Tf 1 0 0 1 20 150 Tm (a) Tj ET /Span <</ActualText (b)>> BDC 100 140 60 20 re f EMC BT /F1 20 Tf 1 0 0 1 200 150 Tm (c) Tj ET",
|
||||
// Glyphs inside the span keep their own box; the paths beside them don't widen it.
|
||||
"/Span <</ActualText (x)>> BDC BT /F1 20 Tf 1 0 0 1 100 150 Tm (y) Tj ET 0 0 300 300 re f EMC",
|
||||
// Paths are not text without a replacement, and an empty one stands for nothing.
|
||||
"100 140 60 20 re f",
|
||||
"/Span <</ActualText ()>> BDC 100 140 60 20 re f EMC",
|
||||
]);
|
||||
let painted = marked_text(&pdf, 0);
|
||||
assert_eq!(painted.plain_text(), "valid");
|
||||
assert_eq!(painted.glyphs[0].rect, [100.0, 240.0, 160.0, 260.0]);
|
||||
assert_eq!(marked_text(&pdf, 1).glyphs[0].rect, [100.0, 200.0, 160.0, 260.0]);
|
||||
let between = marked_text(&pdf, 2);
|
||||
assert_eq!(between.glyphs.iter().map(|g| g.text.as_str()).collect::<Vec<_>>(), ["a", "b", "c"]);
|
||||
let mixed = marked_text(&pdf, 3);
|
||||
assert_eq!(mixed.plain_text(), "x");
|
||||
assert!(mixed.glyphs[0].rect[2] - mixed.glyphs[0].rect[0] < 20.0, "{:?}", mixed.glyphs[0].rect);
|
||||
for page in 4..6 {
|
||||
assert_eq!(marked_text(&pdf, page).plain_text(), "", "page {page}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_painted_filter_leaves_out_replacements_that_mark_no_glyph() {
|
||||
// A page's own text, without words that only /ActualText names.
|
||||
let pdf = marked_pages(&[
|
||||
"/Span <</ActualText (valid)>> BDC 100 40 m 160 40 l 160 60 l 100 60 l f EMC",
|
||||
"BT /F1 20 Tf 1 0 0 1 20 150 Tm (a) Tj ET /Span <</ActualText (b)>> BDC 100 140 60 20 re f EMC BT /F1 20 Tf 1 0 0 1 200 150 Tm (c) Tj ET",
|
||||
]);
|
||||
let own = |page| extract_page_filtered(&pdf, page, &InterpreterSettings::default(), TextFilter { painted: true, ..Default::default() });
|
||||
assert_eq!(own(0).unwrap().plain_text(), "");
|
||||
let between = own(1).unwrap().plain_text();
|
||||
assert!(between.contains('a') && between.contains('c') && !between.contains('b'), "the glyphs stay: {between:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_over_paint_reads_only_where_it_shows() {
|
||||
let pdf = marked_pages(&[
|
||||
// A page-sized fill clipped to a word's box reads at that box.
|
||||
"q 100 40 30 20 re W n /Span <</ActualText (c)>> BDC 0 0 300 300 re f EMC Q",
|
||||
// Wholly clipped away, it reads nowhere.
|
||||
"q 0 0 10 10 re W n /Span <</ActualText (gone)>> BDC 100 40 60 20 re f EMC Q",
|
||||
// A clip that ended before the paint no longer cuts it.
|
||||
"q 0 0 10 10 re W n Q /Span <</ActualText (back)>> BDC 100 40 60 20 re f EMC",
|
||||
]);
|
||||
assert_eq!(marked_text(&pdf, 0).glyphs[0].rect, [100.0, 240.0, 130.0, 260.0]);
|
||||
assert_eq!(marked_text(&pdf, 1).plain_text(), "");
|
||||
assert_eq!(marked_text(&pdf, 2).glyphs[0].rect, [100.0, 240.0, 160.0, 260.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_over_paint_reads_only_inside_the_crop_box() {
|
||||
use lopdf::{Document, Object, Stream, dictionary};
|
||||
let (page, crop) = ([0, 0, 300, 300], [50, 60, 250, 240]);
|
||||
let pages = [
|
||||
// Wholly outside the crop box, as a glyph there is: it reads nowhere.
|
||||
("/Span <</ActualText (below)>> BDC 100 10 60 20 re f EMC", page, crop),
|
||||
// Across its left edge: it reads from that edge, as a glyph across it still shows.
|
||||
("/Span <</ActualText (edge)>> BDC 20 100 60 20 re f EMC", page, crop),
|
||||
// Partly painted outside it: its words may be among what the crop box hides.
|
||||
("/Span <</ActualText (part)>> BDC 100 100 60 20 re f 100 10 60 20 re f EMC", page, crop),
|
||||
// A glyph the crop takes leaves the replacement no glyph, and the paint can't place it.
|
||||
("/Span <</ActualText (split)>> BDC BT /F1 12 Tf 1 0 0 1 100 10 Tm (x) Tj ET 100 100 60 20 re f EMC", page, crop),
|
||||
// On a page wide enough to hold them, paint too far apart to be the place of words.
|
||||
("/Span <</ActualText (far)>> BDC 100 40 10 10 re f 12100 40 10 10 re f EMC", [0, 0, 14400, 300], [0, 0, 14400, 300]),
|
||||
];
|
||||
let boxed = |b: [i64; 4]| b.map(Object::Integer).to_vec();
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
|
||||
let parent = doc.new_object_id();
|
||||
let mut kids = Vec::new();
|
||||
for (ops, media, crop) in pages {
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec()));
|
||||
kids.push(Object::Reference(doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => parent,
|
||||
"MediaBox" => boxed(media),
|
||||
"CropBox" => boxed(crop),
|
||||
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
|
||||
"Contents" => content,
|
||||
})));
|
||||
}
|
||||
let count = kids.len() as i64;
|
||||
doc.objects.insert(parent, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => count }.into());
|
||||
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => parent });
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
doc.save_to(&mut bytes).unwrap();
|
||||
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
|
||||
assert_eq!(marked_text(&pdf, 0).plain_text(), "");
|
||||
// View space starts at the crop box's top-left corner.
|
||||
assert_eq!(marked_text(&pdf, 1).glyphs[0].rect, [0.0, 120.0, 30.0, 140.0]);
|
||||
for page in 2..5 {
|
||||
assert_eq!(marked_text(&pdf, page).plain_text(), "", "page {page}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_over_paint_reads_along_the_way_it_is_drawn() {
|
||||
// The same quarter turn for a path and for text: the replacement runs the way the text does.
|
||||
let pdf = marked_pages(&[
|
||||
"q 0 1 -1 0 150 150 cm /Span <</ActualText (turned)>> BDC 0 0 40 10 re f EMC Q",
|
||||
"q 0 1 -1 0 150 150 cm BT /F1 10 Tf 0 0 Td (t) Tj ET Q",
|
||||
]);
|
||||
let (painted, text) = (marked_text(&pdf, 0), marked_text(&pdf, 1));
|
||||
assert_eq!(painted.plain_text(), "turned");
|
||||
assert_eq!(painted.glyphs[0].direction, text.glyphs[0].direction);
|
||||
let [x0, y0, x1, y1] = painted.glyphs[0].rect;
|
||||
assert_eq!((x1 - x0, y1 - y0), (10.0, 40.0), "40 along the turned line, 10 across it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_over_a_picture_reads_where_it_is_drawn() {
|
||||
// Two pixels square, so a box placed in pixels rather than across the picture shows.
|
||||
let pdf = marked_pages(&["/Span <</ActualText (E=mc2)>> BDC q 60 0 0 20 100 40 cm BI /W 2 /H 2 /CS /G /BPC 8 ID AAAA EI Q EMC"]);
|
||||
let text = marked_text(&pdf, 0);
|
||||
assert_eq!(text.plain_text(), "E=mc2");
|
||||
assert_eq!(text.glyphs[0].rect, [100.0, 240.0, 160.0, 260.0]);
|
||||
// Counting without images leaves it out.
|
||||
let filter = TextFilter { images: true, ..Default::default() };
|
||||
assert_eq!(extract_page_filtered(&pdf, 0, &InterpreterSettings::default(), filter).unwrap().plain_text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluded_glyphs_beside_paint_leave_the_whole_replacement_out() {
|
||||
// The marked glyphs are a figure's, so the replacement goes with them; the paint beside
|
||||
// them neither keeps it nor turns it into a mixed span that can't be counted.
|
||||
let pdf = marked_pages(&[
|
||||
"/Span <</ActualText (f)>> BDC /Figure BMC BT /F1 20 Tf 1 0 0 1 100 150 Tm (x) Tj ET EMC 100 40 60 20 re f EMC",
|
||||
// Paint inside an excluded region is excluded with it.
|
||||
"/Span <</ActualText (z)>> BDC /Figure BMC 100 40 60 20 re f EMC EMC",
|
||||
"/Span <</ActualText (t)>> BDC /Table BMC q 60 0 0 20 100 40 cm BI /W 1 /H 1 /CS /G /BPC 8 ID A EI Q EMC EMC",
|
||||
]);
|
||||
let filtered = |page, filter| extract_page_filtered(&pdf, page, &InterpreterSettings::default(), filter).unwrap().plain_text();
|
||||
let (figures, tables) = (TextFilter { images: true, ..Default::default() }, TextFilter { tables: true, ..Default::default() });
|
||||
assert_eq!(filtered(0, figures.clone()), "");
|
||||
assert_eq!((filtered(1, TextFilter::default()), filtered(1, figures)), ("z".to_string(), String::new()));
|
||||
assert_eq!((filtered(2, TextFilter::default()), filtered(2, tables)), ("t".to_string(), String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_split_by_an_exclusion_is_a_mixed_span_that_cant_be_counted() {
|
||||
// Paint places a replacement that marks no glyph, so paint left out mixes it as excluded
|
||||
// glyphs do: the words left out can't be told from the rest.
|
||||
let picture = "q 60 0 0 20 100 40 cm BI /W 1 /H 1 /CS /G /BPC 8 ID A EI Q";
|
||||
let (table, beside) = (
|
||||
format!("/Span <</ActualText (t)>> BDC /Table BMC {picture} EMC 200 40 60 20 re f EMC"),
|
||||
format!("/Span <</ActualText (p)>> BDC {picture} 200 40 60 20 re f EMC"),
|
||||
);
|
||||
let pdf = marked_pages(&[
|
||||
"/Span <</ActualText (w)>> BDC /Figure BMC 100 40 60 20 re f EMC 200 40 60 20 re f EMC",
|
||||
&table,
|
||||
&beside,
|
||||
// Glyphs place this one, and the paint beside them is not its words.
|
||||
"/Span <</ActualText (g)>> BDC BT /F1 20 Tf 1 0 0 1 100 150 Tm (y) Tj ET /Figure BMC 100 40 60 20 re f EMC EMC",
|
||||
]);
|
||||
let filtered = |page, filter| extract_page_filtered(&pdf, page, &InterpreterSettings::default(), filter).map(|t| t.plain_text());
|
||||
let (figures, tables) = (TextFilter { images: true, ..Default::default() }, TextFilter { tables: true, ..Default::default() });
|
||||
for (page, text, filter) in [(0, "w", figures.clone()), (1, "t", tables), (2, "p", figures.clone())] {
|
||||
assert_eq!(filtered(page, TextFilter::default()), Ok(text.to_string()));
|
||||
assert!(filtered(page, filter).is_err(), "page {page}");
|
||||
}
|
||||
assert_eq!(filtered(3, figures), Ok("g".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotations_never_join_a_replacement_the_page_left_open() {
|
||||
use lopdf::{Document, Object, Stream, dictionary};
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let pages = doc.new_object_id();
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, b"/Span <</ActualText (open)>> BDC 100 40 60 20 re f".to_vec()));
|
||||
let ap = doc.add_object(Stream::new(
|
||||
dictionary! { "Type" => "XObject", "Subtype" => "Form", "BBox" => vec![0.into(), 0.into(), 50.into(), 50.into()] },
|
||||
b"0 0 50 50 re f".to_vec(),
|
||||
));
|
||||
let annot = doc.add_object(dictionary! {
|
||||
"Type" => "Annot", "Subtype" => "Square", "F" => 4,
|
||||
"Rect" => vec![200.into(), 200.into(), 250.into(), 250.into()], "AP" => dictionary! { "N" => ap },
|
||||
});
|
||||
let page = doc.add_object(dictionary! {
|
||||
"Type" => "Page", "Parent" => pages, "Contents" => content, "Annots" => vec![Object::Reference(annot)],
|
||||
"MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)],
|
||||
});
|
||||
doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into());
|
||||
let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages});
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
doc.save_to(&mut bytes).unwrap();
|
||||
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
|
||||
let text = marked_text(&pdf, 0);
|
||||
assert_eq!(text.plain_text(), "open");
|
||||
assert_eq!(text.glyphs[0].rect, [100.0, 240.0, 160.0, 260.0], "the square comment is not part of it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmarked_redraw_of_actual_text_reads_as_the_same_text() {
|
||||
// Canva draws text twice (an outline or shadow) and marks only one drawing. The copy,
|
||||
@@ -1940,28 +2142,141 @@ fn text_outside_the_crop_box_is_clipped() {
|
||||
}
|
||||
}
|
||||
|
||||
// A CropBox that is zero-area or disjoint from the MediaBox leaves an empty visible region,
|
||||
// even when a glyph straddles the clamped intersection point.
|
||||
for crop in [[200, 200, 200, 200], [100, 200, 300, 200], [500, 500, 600, 600]] {
|
||||
// A CropBox that is zero-area or disjoint from the MediaBox is read as a missing one
|
||||
// (vendored hayro-syntax patch 38): the visible region is the MediaBox, as on a page without
|
||||
// a CropBox, not a region of no area.
|
||||
let text_with = |crop: Option<[i64; 4]>| {
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec()));
|
||||
let pages = doc.new_object_id();
|
||||
let page = doc.add_object(dictionary! {
|
||||
let mut page = dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages,
|
||||
"MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)],
|
||||
"CropBox" => crop.map(Object::Integer).to_vec(),
|
||||
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
|
||||
"Contents" => content,
|
||||
});
|
||||
};
|
||||
if let Some(crop) = crop {
|
||||
page.set("CropBox", crop.map(Object::Integer).to_vec());
|
||||
}
|
||||
let page = doc.add_object(page);
|
||||
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
|
||||
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
doc.save_to(&mut bytes).unwrap();
|
||||
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
|
||||
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap();
|
||||
assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text());
|
||||
extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text()
|
||||
};
|
||||
let media = text_with(None);
|
||||
assert!(media.contains("LeftOut") && media.contains("AboveOut") && !media.contains("OutSpan"), "{media:?}");
|
||||
for crop in [[200, 200, 200, 200], [100, 200, 300, 200], [500, 500, 600, 600]] {
|
||||
assert_eq!(text_with(Some(crop)), media, "{crop:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A page box that encloses no area is read as a missing one (vendored hayro-syntax patch 38,
|
||||
/// ISO 32000-2 §14.11.2): a `/MediaBox [0 0 0 0]` as the default page, a `/CropBox [0 0 0 0]`,
|
||||
/// or one with nothing in common with the media box, as the media box. The page's visible region
|
||||
/// had no area, so its text was all outside it and none was extracted (pdf.js
|
||||
/// `boundingBox_invalid`). Each case is checked on both paths: the text is extracted, and the page
|
||||
/// renders at the size of the box that stands in, with the text's ink on it. A page's own box
|
||||
/// without area gives way to an inherited one with area; an inherited box without area falls
|
||||
/// through to the default as an own one does.
|
||||
#[test]
|
||||
fn text_on_a_page_whose_box_has_no_area_is_extracted() {
|
||||
use lopdf::{Document, Object, Stream, dictionary};
|
||||
type Boxes = &'static [(&'static str, [i64; 4])];
|
||||
/// (case, inherited boxes, the page's own boxes, where the word goes, expected size).
|
||||
type Case = (&'static str, Boxes, Boxes, (i64, i64), (u32, u32));
|
||||
/// A one-page document whose `/Pages` node carries `inherited`, and whose page carries `own`
|
||||
/// and draws "Boxed" at `(x, y)` in Helvetica 30 pt.
|
||||
fn boxed(inherited: Boxes, own: Boxes, (x, y): (i64, i64)) -> Vec<u8> {
|
||||
let rect = |r: &[i64; 4]| r.iter().map(|v| Object::Integer(*v)).collect::<Vec<_>>();
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
|
||||
let pages = doc.new_object_id();
|
||||
let ops = format!("BT /F1 30 Tf {x} {y} Td (Boxed) Tj ET");
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, ops.into_bytes()));
|
||||
let mut page = dictionary! { "Type" => "Page", "Parent" => pages, "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content };
|
||||
for (key, r) in own {
|
||||
page.set(*key, rect(r));
|
||||
}
|
||||
let page = doc.add_object(page);
|
||||
let mut node = dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1};
|
||||
for (key, r) in inherited {
|
||||
node.set(*key, rect(r));
|
||||
}
|
||||
doc.objects.insert(pages, node.into());
|
||||
let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages});
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
doc.save_to(&mut bytes).unwrap();
|
||||
bytes
|
||||
}
|
||||
// US Letter (612 × 792 pt, hayro-syntax patch 44) covers 612 × 792 device pixels at scale 1.
|
||||
const LETTER_PX: (u32, u32) = (612, 792);
|
||||
let cases: [Case; 7] = [
|
||||
("own MediaBox without area", &[], &[("MediaBox", [0, 0, 0, 0])], (25, 500), LETTER_PX),
|
||||
("own CropBox without area", &[], &[("MediaBox", [0, 0, 800, 600]), ("CropBox", [0, 0, 0, 0])], (25, 500), (800, 600)),
|
||||
(
|
||||
"CropBox touching the media box at a corner",
|
||||
&[],
|
||||
&[("MediaBox", [0, 0, 600, 800]), ("CropBox", [600, 800, 1000, 1000])],
|
||||
(25, 500),
|
||||
(600, 800),
|
||||
),
|
||||
// Text past US Letter but inside the inherited box is kept.
|
||||
(
|
||||
"own MediaBox without area, inherited one with area",
|
||||
&[("MediaBox", [0, 0, 1200, 1200])],
|
||||
&[("MediaBox", [0, 0, 0, 0])],
|
||||
(700, 900),
|
||||
(1200, 1200),
|
||||
),
|
||||
(
|
||||
"own CropBox without area, inherited one with area",
|
||||
&[("CropBox", [0, 0, 300, 300])],
|
||||
&[("MediaBox", [0, 0, 600, 800]), ("CropBox", [0, 0, 0, 0])],
|
||||
(25, 100),
|
||||
(300, 300),
|
||||
),
|
||||
("inherited MediaBox without area", &[("MediaBox", [0, 0, 0, 0])], &[], (25, 500), LETTER_PX),
|
||||
("inherited CropBox outside the media box", &[("CropBox", [600, 600, 900, 900])], &[("MediaBox", [0, 0, 500, 500])], (25, 400), (500, 500)),
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
for (case, inherited, own, at, size) in cases {
|
||||
let bytes = boxed(inherited, own, at);
|
||||
let pdf = Pdf::new(std::sync::Arc::new(bytes.clone())).unwrap();
|
||||
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).map(|t| t.plain_text().trim().to_string());
|
||||
if text.as_deref() != Some("Boxed") {
|
||||
failures.push(format!("{case}: text {text:?}"));
|
||||
}
|
||||
let mut renderer = crate::PageRenderer::new(std::sync::Arc::new(bytes), crate::RenderConfig::default());
|
||||
let out = renderer.render(crate::RenderRequest { page: 0, kind: crate::RequestKind::Pixels, scale: 1.0, ..Default::default() });
|
||||
let ink = out.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 128 && p[1] < 128 && p[2] < 128).count();
|
||||
if out.error.is_some() || (out.width, out.height) != size || ink < 50 {
|
||||
failures.push(format!(
|
||||
"{case}: rendered {}×{} with {ink} dark pixels ({:?}), expected {}×{} with ink",
|
||||
out.width, out.height, out.error, size.0, size.1
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{failures:#?}");
|
||||
}
|
||||
|
||||
/// The glyph under a point is the first whose box holds it, edges included; between glyphs, none.
|
||||
#[test]
|
||||
fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() {
|
||||
let text = PageText {
|
||||
glyphs: vec![em_box("a", 0.0, 0.0, 10.0, 10.0), em_box("b", 8.0, 0.0, 20.0, 10.0)],
|
||||
line_of: vec![0, 0],
|
||||
space_before: vec![false, false],
|
||||
};
|
||||
assert_eq!(text.glyph_at(5.0, 5.0), Some(0));
|
||||
assert_eq!(text.glyph_at(9.0, 5.0), Some(0), "the first of overlapping boxes");
|
||||
assert_eq!(text.glyph_at(20.0, 10.0), Some(1), "an edge is in");
|
||||
assert_eq!(text.glyph_at(25.0, 5.0), None);
|
||||
assert_eq!(PageText::default().glyph_at(0.0, 0.0), None);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Contributor-original synthetic pages for combined fill-and-stroke operators (`B`, `B*`, `b`,
|
||||
//! `b*`) and text rendering modes 2 and 6 (`FillStroke`, `FillAndStrokeAndClip`) under
|
||||
//! transparency, blend modes, and soft masks (ISO 32000-2 §11.7.4.4).
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn pdf_with_content(content: &str) -> Vec<u8> {
|
||||
let mask_stream = b"0.5 g 0 0 200 200 re f";
|
||||
let objects: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
|
||||
/Group << /S /Transparency /CS /DeviceRGB >> \
|
||||
/Contents 4 0 R \
|
||||
/Resources << \
|
||||
/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> \
|
||||
/ExtGState << \
|
||||
/GS50 << /Type /ExtGState /ca 0.5 /CA 0.5 >> \
|
||||
/GSStroke50 << /Type /ExtGState /ca 1.0 /CA 0.5 >> \
|
||||
/GSMul << /Type /ExtGState /BM /Multiply /ca 1.0 /CA 1.0 >> \
|
||||
/GSMask << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 5 0 R >> >> \
|
||||
>> \
|
||||
>> >>"
|
||||
.to_vec(),
|
||||
stream(b"", content.as_bytes()),
|
||||
stream(
|
||||
b"/Type /XObject /Subtype /Form /BBox [0 0 200 200] \
|
||||
/Group << /S /Transparency /CS /DeviceGray /I true >> ",
|
||||
mask_stream,
|
||||
),
|
||||
];
|
||||
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(object);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn stream(dict_entries: &[u8], body: &[u8]) -> Vec<u8> {
|
||||
let mut s = b"<< ".to_vec();
|
||||
s.extend_from_slice(dict_entries);
|
||||
s.extend_from_slice(format!("/Length {} >>\nstream\n", body.len()).as_bytes());
|
||||
s.extend_from_slice(body);
|
||||
s.extend_from_slice(b"\nendstream");
|
||||
s
|
||||
}
|
||||
|
||||
struct Page(Vec<u8>);
|
||||
|
||||
impl Page {
|
||||
fn render(content: &str) -> Self {
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf_with_content(content)), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
|
||||
assert!(page.error.is_none(), "{content}: {:?}", page.error);
|
||||
assert_eq!((page.width, page.height), (200, 200), "{content}");
|
||||
Self(page.rgba.to_vec())
|
||||
}
|
||||
|
||||
fn at(&self, x: usize, y: usize) -> [u8; 3] {
|
||||
let i = ((199 - y) * 200 + x) * 4;
|
||||
match self.0.get(i..i + 3) {
|
||||
Some([r, g, b]) => [*r, *g, *b],
|
||||
_ => panic!("pixel ({x}, {y}) is out of bounds"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_close(&self, x: usize, y: usize, want: [u8; 3], label: &str) {
|
||||
let got = self.at(x, y);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{label} at ({x}, {y}): got {got:?}, expected {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_stroke_path_knocks_out_fill_under_stroke() {
|
||||
// 50% red fill (`ca = 0.5`) and 50% blue 20 pt stroke (`CA = 0.5`) on a white page.
|
||||
// The stroke spans x = 30..50 along the left edge of the 40..160 square:
|
||||
// - outer half of stroke at (35, 100) is over white -> [128, 128, 255]
|
||||
// - inner half of stroke at (45, 100) knocks out the fill -> [128, 128, 255]
|
||||
// - interior fill at (100, 100) is 50% red over white -> [255, 128, 128]
|
||||
for op in ["40 40 120 120 re B", "40 40 120 120 re B*", "40 40 m 160 40 l 160 160 l 40 160 l b", "40 40 m 160 40 l 160 160 l 40 160 l b*"] {
|
||||
let page = Page::render(&format!("q /GS50 gs 1 0 0 rg 0 0 1 RG 20 w {op} Q"));
|
||||
page.assert_close(100, 100, [255, 128, 128], &format!("{op} fill center"));
|
||||
page.assert_close(35, 100, [128, 128, 255], &format!("{op} outer stroke"));
|
||||
page.assert_close(45, 100, [128, 128, 255], &format!("{op} inner stroke"));
|
||||
}
|
||||
|
||||
// Even when fill alpha is 1.0 (`ca = 1.0`) and only stroke alpha is 0.5 (`CA = 0.5`),
|
||||
// the semi-transparent stroke knocks out the opaque fill beneath it and composites
|
||||
// directly with the white backdrop.
|
||||
let page = Page::render("q /GSStroke50 gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re B Q");
|
||||
page.assert_close(100, 100, [255, 0, 0], "opaque fill center");
|
||||
page.assert_close(35, 100, [128, 128, 255], "CA=0.5 outer stroke");
|
||||
page.assert_close(45, 100, [128, 128, 255], "CA=0.5 inner stroke knocks out opaque fill");
|
||||
|
||||
// Control: separate `f` followed by `S` are two distinct graphics objects, so the stroke
|
||||
// composites over the already-painted fill: 0.5 * (0, 0, 255) + 0.5 * (255, 128, 128) = (128, 64, 191).
|
||||
let separate = Page::render("q /GS50 gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re f 40 40 120 120 re S Q");
|
||||
separate.assert_close(35, 100, [128, 128, 255], "separate outer stroke");
|
||||
separate.assert_close(45, 100, [128, 64, 191], "separate inner stroke over fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_stroke_path_applies_blend_mode_and_soft_mask_once() {
|
||||
// Under `/BM /Multiply` on a (0.8, 0.8, 0.5) backdrop, `B` knocks out the yellow fill
|
||||
// (1.0, 0.8, 0.0) under the cyan stroke (0.0, 0.8, 1.0) before multiplying with the backdrop:
|
||||
// - fill center: (1.0 * 0.8, 0.8 * 0.8, 0.0 * 0.5) = [204, 163, 0]
|
||||
// - both halves of the stroke: (0.0 * 0.8, 0.8 * 0.8, 1.0 * 0.5) = [0, 163, 128]
|
||||
let mul = Page::render(
|
||||
"0.8 0.8 0.5 rg 0 0 200 200 re f \
|
||||
q /GSMul gs 1 0.8 0 rg 0 0.8 1 RG 20 w 40 40 120 120 re B Q",
|
||||
);
|
||||
mul.assert_close(100, 100, [204, 163, 0], "Multiply fill center");
|
||||
mul.assert_close(35, 100, [0, 163, 128], "Multiply outer stroke");
|
||||
mul.assert_close(45, 100, [0, 163, 128], "Multiply inner stroke");
|
||||
|
||||
// Under a 50% luminosity `/SMask`, `B` masks the combined knockout group once:
|
||||
// - fill center: 50% red over white = [255, 128, 128]
|
||||
// - both halves of the stroke: 50% blue over white = [128, 128, 255]
|
||||
let masked = Page::render("q /GSMask gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re B Q");
|
||||
masked.assert_close(100, 100, [255, 128, 128], "SMask fill center");
|
||||
masked.assert_close(35, 100, [128, 128, 255], "SMask outer stroke");
|
||||
masked.assert_close(45, 100, [128, 128, 255], "SMask inner stroke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_rendering_modes_2_and_6_knock_out_fill_under_stroke() {
|
||||
for mode in [2, 6] {
|
||||
let page = Page::render(&format!("q /GS50 gs 1 0 0 rg 0 0 1 RG 12 w BT /F1 120 Tf 30 50 Td {mode} Tr (I) Tj ET Q"));
|
||||
// Both the 50% red fill ([255, 128, 128]) and the 50% blue stroke ([128, 128, 255]) over
|
||||
// white have G >= 128. With knockout, every pixel (fill, inner stroke, outer stroke, and
|
||||
// anti-aliased edges) stays at G >= 126; without knockout, the inner half of the stroke
|
||||
// composites over the fill to [128, 64, 191] (G = 64).
|
||||
let mut stroke_pixels = 0usize;
|
||||
let mut fill_pixels = 0usize;
|
||||
for &[r, g, b, _] in page.0.as_chunks::<4>().0 {
|
||||
assert!(g >= 126, "mode {mode} Tr pixel [{r}, {g}, {b}] composited stroke over fill");
|
||||
if b >= 253 && r <= 130 {
|
||||
stroke_pixels += 1;
|
||||
} else if r >= 253 && b <= 130 {
|
||||
fill_pixels += 1;
|
||||
}
|
||||
}
|
||||
assert!(stroke_pixels > 200, "mode {mode} Tr should paint stroke pixels");
|
||||
assert!(fill_pixels > 100, "mode {mode} Tr should paint interior fill pixels");
|
||||
}
|
||||
|
||||
// Mode 6 (`FillAndStrokeAndClip`) also clips subsequent drawing after `ET` to the glyph outline.
|
||||
let clipped = Page::render(
|
||||
"q /GS50 gs 1 0 0 rg 0 0 1 RG 12 w BT /F1 120 Tf 30 50 Td 6 Tr (I) Tj ET \
|
||||
1.0 ca 0 1 0 rg 0 0 200 200 re f Q",
|
||||
);
|
||||
// Outside the glyph (10, 10), the green rectangle is clipped away.
|
||||
clipped.assert_close(10, 10, [255, 255, 255], "outside 6 Tr glyph clip");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod cid_fallback_query;
|
||||
mod fill_stroke_knockout;
|
||||
mod inline_image_ei;
|
||||
mod jbig2_invariants;
|
||||
mod knockout_backdrop;
|
||||
@@ -13,6 +14,7 @@ mod search;
|
||||
mod separation_all;
|
||||
mod stray_q_in_a_form;
|
||||
mod stroke_degenerate_cubic;
|
||||
mod stroke_zero_end_tangent;
|
||||
mod substitute_widths;
|
||||
mod tiling_cell_placement;
|
||||
mod type3_text_clip;
|
||||
|
||||
@@ -12,28 +12,53 @@ fn page(parts: &[&str]) -> PageText {
|
||||
// The original straightforward search is an independent oracle for valid page metadata.
|
||||
fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: usize) -> Vec<Range<usize>> {
|
||||
let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) };
|
||||
let needle: Vec<char> = fold(query).split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
|
||||
// Soft hyphens and zero width characters draw nothing and are skipped on both sides.
|
||||
let visible =
|
||||
|s: String| s.chars().filter(|c| !matches!(c, '\u{AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}')).collect::<String>();
|
||||
let needle: Vec<char> = visible(fold(query)).split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
|
||||
if needle.is_empty() || limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut chars = Vec::new();
|
||||
// Each character, its glyph, and whether a skipped zero width space came right before it.
|
||||
let mut chars: Vec<(char, usize, bool)> = Vec::new();
|
||||
let mut after_zwsp = false;
|
||||
for (i, glyph) in page.glyphs.iter().enumerate() {
|
||||
if i > 0 && (page.space_before[i] || page.line_of[i] != page.line_of[i - 1]) && chars.last().is_some_and(|&(c, _)| c != ' ') {
|
||||
chars.push((' ', i));
|
||||
// A line that ends in a soft hyphen, past any empty or other invisible glyphs and
|
||||
// characters after it on that line, runs on to the next.
|
||||
let ends_in_soft_hyphen = |end: usize| {
|
||||
(0..=end)
|
||||
.rev()
|
||||
.take_while(|&k| page.line_of[k] == page.line_of[end])
|
||||
.flat_map(|k| page.glyphs[k].text.chars().rev())
|
||||
.find(|&c| c == '\u{AD}' || !visible(c.to_string()).is_empty())
|
||||
== Some('\u{AD}')
|
||||
};
|
||||
let line_break = i > 0 && page.line_of[i] != page.line_of[i - 1] && !ends_in_soft_hyphen(i - 1);
|
||||
if i > 0 && (page.space_before[i] || line_break) && chars.last().is_some_and(|&(c, ..)| c != ' ') {
|
||||
chars.push((' ', i, false));
|
||||
after_zwsp = false;
|
||||
}
|
||||
for c in fold(&glyph.text).chars() {
|
||||
after_zwsp |= c == '\u{200B}';
|
||||
if visible(c.to_string()).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let c = if c.is_whitespace() { ' ' } else { c };
|
||||
if c != ' ' || chars.last().is_none_or(|&(last, _)| last != ' ') {
|
||||
chars.push((c, i));
|
||||
if c != ' ' || chars.last().is_none_or(|&(last, ..)| last != ' ') {
|
||||
chars.push((c, i, after_zwsp && c != ' '));
|
||||
after_zwsp = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let word = |i: Option<usize>| i.and_then(|i| chars.get(i)).is_some_and(|&(c, _)| c.is_alphanumeric());
|
||||
let word = |i: Option<usize>| i.and_then(|i| chars.get(i)).is_some_and(|&(c, ..)| c.is_alphanumeric());
|
||||
// A word ends at a skipped zero width space as it does at a space.
|
||||
let edge = |i: usize| chars.get(i).is_some_and(|&(.., zwsp)| zwsp);
|
||||
let mut hits = Vec::new();
|
||||
let mut start = 0;
|
||||
while start + needle.len() <= chars.len() {
|
||||
let end = start + needle.len();
|
||||
if (!whole || (!word(start.checked_sub(1)) && !word(Some(end)))) && chars[start..end].iter().map(|&(c, _)| c).eq(needle.iter().copied()) {
|
||||
let bounded = (edge(start) || !word(start.checked_sub(1))) && (edge(end) || !word(Some(end)));
|
||||
if (!whole || bounded) && chars[start..end].iter().map(|&(c, ..)| c).eq(needle.iter().copied()) {
|
||||
hits.push(chars[start].1..chars[end - 1].1 + 1);
|
||||
if hits.len() == limit {
|
||||
break;
|
||||
@@ -69,7 +94,7 @@ fn whole_word_rejection_keeps_overlapping_candidates() {
|
||||
|
||||
#[test]
|
||||
fn search_matches_reference_for_unicode_gaps_limits_and_word_boundaries() {
|
||||
let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية"];
|
||||
let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية", "\u{AD}", "a\u{200B}"];
|
||||
let mut seed = 1234567u64;
|
||||
let mut next = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
@@ -110,3 +135,486 @@ fn long_repeated_prefix_search_preserves_the_last_match_and_limit() {
|
||||
assert_eq!(text.find_opts_limited(&query, false, false, 1), vec![126977..131073]);
|
||||
assert!(text.find(&format!("{}c", "a".repeat(4095))).is_empty());
|
||||
}
|
||||
|
||||
/// pdf.js issue 13226 stores "Mitarbeiterinnen" with soft hyphens at its hyphenation points, and
|
||||
/// issue 9186 puts zero width spaces next to the gaps between words. Find skips both characters
|
||||
/// (and the other invisible joiners) in the page and in the query, and a match still starts and
|
||||
/// ends on visible letters.
|
||||
#[test]
|
||||
fn find_skips_soft_hyphens_and_zero_width_characters() {
|
||||
let word = page(&["M", "i", "t", "\u{AD}", "a", "r", "b", "e", "i", "\u{AD}", "t", "e", "r", "i", "n", "n", "e", "n"]);
|
||||
assert_eq!(word.find("Mitarbeiterinnen"), vec![0..18]);
|
||||
assert_eq!(word.find_opts("Mitarbeiterinnen", true, true), vec![0..18]);
|
||||
assert_eq!(word.find("arbei"), vec![4..9]);
|
||||
// Inside one word, a piece of it is not a whole word.
|
||||
assert!(word.find_opts("terinnen", false, true).is_empty());
|
||||
// A query that carries the soft hyphens still matches.
|
||||
assert_eq!(word.find("Mit\u{AD}arbei\u{AD}terinnen"), vec![0..18]);
|
||||
|
||||
let mut gaps = page(&["is", "\u{200B}", "\u{200B}", "made", "\u{2060}", "as"]);
|
||||
gaps.space_before = vec![false, false, true, false, false, true];
|
||||
assert_eq!(gaps.find("is made"), vec![0..4]);
|
||||
assert_eq!(gaps.find_opts("made as", false, true), vec![3..6]);
|
||||
for joiner in ['\u{200C}', '\u{200D}', '\u{2060}', '\u{FEFF}'] {
|
||||
assert_eq!(page(&["ab", &joiner.to_string(), "cd"]).find("abcd"), vec![0..3], "{joiner:?}");
|
||||
// The same character typed in the query is skipped too.
|
||||
assert_eq!(page(&["ab", "cd"]).find_opts(&format!("ab{joiner}cd"), true, false), vec![0..2], "{joiner:?}");
|
||||
}
|
||||
assert!(page(&["\u{200B}"]).find("\u{200B}").is_empty());
|
||||
}
|
||||
|
||||
/// A soft hyphen at a line end is where the word was divided (ISO 32000-2 §14.8.2.3): the break
|
||||
/// after it is no word break, so "Mitar\u{AD}" ending one line and "beiterinnen" starting the next
|
||||
/// are found as one word, highlighted on both lines. A line break after any other glyph still
|
||||
/// separates words.
|
||||
#[test]
|
||||
fn find_joins_a_word_divided_at_a_line_end_soft_hyphen() {
|
||||
let parts: Vec<String> = "Mitar\u{AD}beiterinnen".chars().map(String::from).collect();
|
||||
let mut text = page(&parts.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
for (i, glyph) in text.glyphs.iter_mut().enumerate() {
|
||||
let x = i as f32 * 10.0;
|
||||
glyph.rect = [x, 0.0, x + 10.0, 10.0];
|
||||
}
|
||||
// "Mitar" and the soft hyphen on the first line, "beiterinnen" on the second.
|
||||
for line in text.line_of.iter_mut().skip(6) {
|
||||
*line = 1;
|
||||
}
|
||||
let hits = text.find("Mitarbeiterinnen");
|
||||
assert_eq!(hits, vec![0..17]);
|
||||
assert_eq!(text.line_rects(hits[0].clone()).len(), 2);
|
||||
assert_eq!(text.find_opts("Mitarbeiterinnen", true, true), vec![0..17]);
|
||||
assert!(text.find("Mitar beiterinnen").is_empty());
|
||||
|
||||
// The soft hyphen as the last character of a longer glyph works the same way.
|
||||
let mut text = page(&["Mitar\u{AD}", "beiterinnen"]);
|
||||
text.line_of = vec![0, 1];
|
||||
assert_eq!(text.find("Mitarbeiterinnen"), vec![0..2]);
|
||||
|
||||
// Without the soft hyphen the line break is a word break.
|
||||
let mut text = page(&["Mitar", "beiterinnen"]);
|
||||
text.line_of = vec![0, 1];
|
||||
assert!(text.find("Mitarbeiterinnen").is_empty());
|
||||
assert_eq!(text.find("Mitar beiterinnen"), vec![0..2]);
|
||||
}
|
||||
|
||||
/// Horizontal glyphs of `size` points, one per character, each line `lead` points below the
|
||||
/// last and starting at `x`: (x, first line's top, text) per line.
|
||||
fn laid_out(lines: &[(f32, f32, &str)], size: f32) -> PageText {
|
||||
let mut glyphs = Vec::new();
|
||||
for &(x0, top, text) in lines {
|
||||
for (k, c) in text.chars().enumerate() {
|
||||
let x = x0 + size * k as f32;
|
||||
let (y0, y1) = (top, top + size);
|
||||
glyphs.push(TextGlyph {
|
||||
text: c.into(),
|
||||
rect: [x, y0, x + size, y1],
|
||||
quad: [[x, y1], [x + size, y1], [x + size, y0], [x, y0]],
|
||||
direction: [1.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
pdfcraft_render::text::layout(glyphs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chinese_or_japanese_word_divided_by_a_line_end_is_found_on_both_lines() {
|
||||
// "検知機構" wraps after "検知" (pdf.js #15340); "ソレノイド" after "ソレ".
|
||||
let text = laid_out(&[(10.0, 10.0, "装置の検知"), (10.0, 25.0, "機構です"), (10.0, 40.0, "ソレ"), (10.0, 55.0, "ノイド")], 10.0);
|
||||
assert_eq!(text.line_of.iter().copied().max(), Some(3));
|
||||
let hits = text.find("検知機構");
|
||||
assert_eq!(hits, vec![3..7]);
|
||||
// One highlight per line, the end of the first line and the start of the second.
|
||||
assert_eq!(text.line_rects(hits[0].clone()), vec![[40.0, 10.0, 60.0, 20.0], [10.0, 25.0, 30.0, 35.0]]);
|
||||
assert_eq!(text.find("ソレノイド"), vec![9..14]);
|
||||
// The query typed with a space still finds nothing across the joined break.
|
||||
assert!(text.find("検知 機構").is_empty());
|
||||
// Copied text keeps the line break.
|
||||
assert_eq!(text.text_of(3..7), "検知\n機構");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_break_stays_a_space_beside_latin_or_hangul_and_between_side_by_side_text() {
|
||||
// Latin and Korean separate words with spaces: the line end is one.
|
||||
let latin = laid_out(&[(10.0, 10.0, "abc"), (10.0, 25.0, "def")], 10.0);
|
||||
assert_eq!(latin.find("abc def"), vec![0..6]);
|
||||
assert!(latin.find("abcdef").is_empty());
|
||||
let mixed = laid_out(&[(10.0, 10.0, "PDF"), (10.0, 25.0, "検知")], 10.0);
|
||||
assert_eq!(mixed.find("PDF 検知"), vec![0..5]);
|
||||
assert!(mixed.find("PDF検知").is_empty());
|
||||
let hangul = laid_out(&[(10.0, 10.0, "한국"), (10.0, 25.0, "어")], 10.0);
|
||||
assert_eq!(hangul.find("한국 어"), vec![0..3]);
|
||||
assert!(hangul.find("한국어").is_empty());
|
||||
// Two cells on one row read as two lines, but the second does not continue the first.
|
||||
let row = laid_out(&[(10.0, 10.0, "東京"), (200.0, 10.0, "大阪")], 10.0);
|
||||
assert_eq!(row.line_of, vec![0, 0, 1, 1]);
|
||||
assert!(row.find("東京大阪").is_empty());
|
||||
assert_eq!(row.find("東京 大阪"), vec![0..4]);
|
||||
// Nor does a cell set lower and further along the row: a wrapped line starts back behind the
|
||||
// end of the line before it.
|
||||
let staggered = laid_out(&[(10.0, 10.0, "東京"), (200.0, 17.0, "大阪")], 10.0);
|
||||
assert_eq!(staggered.line_of, vec![0, 0, 1, 1]);
|
||||
assert!(staggered.find("東京大阪").is_empty());
|
||||
assert_eq!(staggered.find("東京 大阪"), vec![0..4]);
|
||||
// Fullwidth Latin letters and digits are Latin text set wide: their line end is a word break.
|
||||
let wide = laid_out(&[(10.0, 10.0, "AB"), (10.0, 25.0, "CD"), (10.0, 40.0, "12"), (10.0, 55.0, "34")], 10.0);
|
||||
assert!(wide.find_opts("ABCD", true, false).is_empty());
|
||||
assert_eq!(wide.find_opts("AB CD", true, false), vec![0..4]);
|
||||
assert!(wide.find("1234").is_empty());
|
||||
assert_eq!(wide.find("12 34"), vec![4..8]);
|
||||
}
|
||||
|
||||
/// Vertical glyphs (WMode 1) of `size` points, one per character, written downwards: each
|
||||
/// column starts at `top` with its left edge at `x`: (x, top, text) per column, in reading order.
|
||||
/// Each column is one line, as the text device groups a vertical flow.
|
||||
fn columns(cols: &[(f32, f32, &str)], size: f32) -> PageText {
|
||||
let mut text = PageText::default();
|
||||
for (line, &(x, top, chars)) in (0u32..).zip(cols) {
|
||||
for (k, c) in chars.chars().enumerate() {
|
||||
let y = top + size * k as f32;
|
||||
// Written downwards: the em box's edge towards the next line is its left one.
|
||||
text.glyphs.push(TextGlyph {
|
||||
text: c.into(),
|
||||
rect: [x, y, x + size, y + size],
|
||||
quad: [[x, y], [x, y + size], [x + size, y + size], [x + size, y]],
|
||||
direction: [0.0, 1.0],
|
||||
});
|
||||
text.line_of.push(line);
|
||||
text.space_before.push(false);
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_divided_between_vertical_columns_is_found_in_both() {
|
||||
// Japanese set vertically: columns run top to bottom and follow each other to the left.
|
||||
// "検知機構" breaks after "検知" at the foot of the right column.
|
||||
let text = columns(&[(40.0, 10.0, "装置の検知"), (25.0, 10.0, "機構です")], 10.0);
|
||||
let hits = text.find("検知機構");
|
||||
assert_eq!(hits, vec![3..7]);
|
||||
// One highlight per column: the foot of the right one and the head of the left one.
|
||||
assert_eq!(text.line_rects(hits[0].clone()), vec![[40.0, 40.0, 50.0, 60.0], [25.0, 10.0, 35.0, 30.0]]);
|
||||
assert_eq!(
|
||||
text.text_of(3..7),
|
||||
"検知
|
||||
機構"
|
||||
);
|
||||
|
||||
// A column that stands to the right of the one before does not continue it.
|
||||
let back = columns(&[(40.0, 10.0, "装置の検知"), (55.0, 10.0, "機構です")], 10.0);
|
||||
assert!(back.find("検知機構").is_empty());
|
||||
assert_eq!(back.find("検知 機構"), vec![3..7]);
|
||||
}
|
||||
|
||||
/// A zero width space draws nothing and is skipped, but UAX #29 breaks words on both sides of it:
|
||||
/// Thai and Khmer write it between words with no visible gap, so each word is still a whole word.
|
||||
/// The joiners and the soft hyphen join, so they are no boundary.
|
||||
#[test]
|
||||
fn a_zero_width_space_is_a_word_boundary_for_whole_words() {
|
||||
let thai = page(&["สวัสดี", "\u{200B}", "ครับ"]);
|
||||
assert_eq!(thai.find_opts("ครับ", false, true), vec![2..3]);
|
||||
assert_eq!(thai.find_opts("สวัสดี", false, true), vec![0..1]);
|
||||
// The words read together, as typed with no space, still match.
|
||||
assert_eq!(thai.find_opts("สวัสดีครับ", false, true), vec![0..3]);
|
||||
// Inside one glyph's text too.
|
||||
assert_eq!(page(&["ab\u{200B}cd"]).find_opts("cd", false, true), vec![0..1]);
|
||||
for joiner in ["\u{AD}", "\u{200C}", "\u{200D}", "\u{2060}", "\u{FEFF}"] {
|
||||
let text = page(&["ab", joiner, "cd"]);
|
||||
assert!(text.find_opts("cd", false, true).is_empty(), "{joiner:?}");
|
||||
assert!(text.find_opts("ab", false, true).is_empty(), "{joiner:?}");
|
||||
assert_eq!(text.find_opts("abcd", false, true), vec![0..3], "{joiner:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every range `unspaced` lists joins a line it ends to the next one, on either side of the break;
|
||||
/// Hangul, parenthesised and circled Hangul, fullwidth Latin and digits, Yi and ASCII keep a space.
|
||||
#[test]
|
||||
fn each_unspaced_range_joins_and_spaced_scripts_do_not() {
|
||||
let joined = [
|
||||
'\u{2E80}',
|
||||
'\u{2F00}', // CJK radicals, Kangxi radicals
|
||||
'\u{3001}',
|
||||
'\u{3006}', // CJK symbols and punctuation
|
||||
'\u{3042}',
|
||||
'\u{30A2}', // Hiragana, Katakana
|
||||
'\u{3105}', // Bopomofo
|
||||
'\u{3190}',
|
||||
'\u{31A0}',
|
||||
'\u{31C0}',
|
||||
'\u{31F0}', // Kanbun, Bopomofo extended, strokes, Katakana extensions
|
||||
'\u{3220}',
|
||||
'\u{3300}',
|
||||
'\u{3400}', // enclosed ideograph, CJK compatibility, Han extension A
|
||||
'\u{4E2D}', // Han
|
||||
'\u{F900}', // CJK compatibility ideographs
|
||||
'\u{FE10}',
|
||||
'\u{FE30}',
|
||||
'\u{FE50}', // vertical, compatibility and small forms
|
||||
'\u{FF01}',
|
||||
'\u{FF1A}',
|
||||
'\u{FF3B}',
|
||||
'\u{FF5B}',
|
||||
'\u{FFE0}', // fullwidth punctuation and signs
|
||||
'\u{FF61}',
|
||||
'\u{FF71}', // halfwidth CJK punctuation, halfwidth Katakana
|
||||
'\u{16FE0}',
|
||||
'\u{1AFF0}',
|
||||
'\u{1B000}', // ideographic symbols, kana extended and supplement
|
||||
'\u{20000}',
|
||||
'\u{2F800}',
|
||||
'\u{30000}', // Han extension B, compatibility supplement, extension G
|
||||
];
|
||||
for c in joined {
|
||||
let text = laid_out(&[(10.0, 10.0, &format!("中{c}")), (10.0, 25.0, &format!("{c}中"))], 10.0);
|
||||
assert_eq!(text.find_opts(&format!("中{c}{c}中"), true, false), vec![0..4], "U+{:04X}", c as u32);
|
||||
}
|
||||
let spaced = ['\u{AC00}', '\u{3200}', '\u{3260}', '\u{FF10}', '\u{FF21}', '\u{FF41}', '\u{FFA1}', '\u{A000}', 'a'];
|
||||
for c in spaced {
|
||||
let text = laid_out(&[(10.0, 10.0, &format!("中{c}")), (10.0, 25.0, &format!("{c}中"))], 10.0);
|
||||
assert!(text.find_opts(&format!("中{c}{c}中"), true, false).is_empty(), "U+{:04X}", c as u32);
|
||||
assert_eq!(text.find_opts(&format!("中{c} {c}中"), true, false), vec![0..4], "U+{:04X}", c as u32);
|
||||
}
|
||||
}
|
||||
|
||||
/// The two line-end rules meet: a soft hyphen after a Chinese or Japanese character, a line of
|
||||
/// Chinese that runs on, a German word divided by a soft hyphen, and a line that opens with a zero
|
||||
/// width space, all on one page. Each break reads as nothing, and plain Latin line ends stay spaces.
|
||||
#[test]
|
||||
fn a_soft_hyphen_and_a_chinese_or_japanese_line_end_combine() {
|
||||
let text = laid_out(
|
||||
&[
|
||||
(10.0, 10.0, "装置の検\u{AD}"),
|
||||
(10.0, 25.0, "知機構の"),
|
||||
(10.0, 40.0, "ソレ"),
|
||||
(10.0, 55.0, "\u{200B}ノイド"),
|
||||
(10.0, 70.0, "Mitar\u{AD}"),
|
||||
(10.0, 85.0, "beiterinnen"),
|
||||
(10.0, 100.0, "abc"),
|
||||
(10.0, 115.0, "def"),
|
||||
],
|
||||
10.0,
|
||||
);
|
||||
// Both rules fire at the first break: one hit, highlighted on both lines.
|
||||
let hits = text.find("検知機構");
|
||||
assert_eq!(hits, vec![3..8]);
|
||||
assert_eq!(text.line_rects(hits[0].clone()).len(), 2);
|
||||
assert!(text.find("検 知機構").is_empty());
|
||||
// A line of Chinese or Japanese with no soft hyphen, and one that opens with a zero width space.
|
||||
assert_eq!(text.find("機構のソレノイド"), vec![6..15]);
|
||||
// A soft hyphen between Latin letters.
|
||||
assert_eq!(text.find_opts("Mitarbeiterinnen", true, true), vec![15..32]);
|
||||
// Latin with neither: still a word break.
|
||||
assert_eq!(text.find("abc def"), vec![32..38]);
|
||||
assert!(text.find("abcdef").is_empty());
|
||||
}
|
||||
|
||||
/// Glyphs of `size` points as [`laid_out`] sets them, each line `lead` below the last, with the
|
||||
/// writing direction turned to `direction` about the first glyph's corner (a unit vector in
|
||||
/// y-down view space; `[1, 0]` is [`laid_out`]).
|
||||
fn turned(lines: &[&str], size: f32, lead: f32, direction: [f32; 2]) -> PageText {
|
||||
let [dx, dy] = direction;
|
||||
// Towards the next line: the writing direction turned a quarter clockwise in view space.
|
||||
let [nx, ny] = [-dy, dx];
|
||||
let at = |along: f32, across: f32| [100.0 + along * dx + across * nx, 100.0 + along * dy + across * ny];
|
||||
let mut glyphs = Vec::new();
|
||||
for (l, line) in lines.iter().enumerate() {
|
||||
for (k, c) in line.chars().enumerate() {
|
||||
let (a, b) = (size * k as f32, lead * l as f32);
|
||||
// Baseline edge first, in the writing direction; then the far edge back.
|
||||
let quad = [at(a, b + size), at(a + size, b + size), at(a + size, b), at(a, b)];
|
||||
let xs = quad.map(|p| p[0]);
|
||||
let ys = quad.map(|p| p[1]);
|
||||
let rect = [
|
||||
xs.iter().copied().fold(f32::MAX, f32::min),
|
||||
ys.iter().copied().fold(f32::MAX, f32::min),
|
||||
xs.iter().copied().fold(f32::MIN, f32::max),
|
||||
ys.iter().copied().fold(f32::MIN, f32::max),
|
||||
];
|
||||
glyphs.push(TextGlyph { text: c.into(), rect, quad, direction });
|
||||
}
|
||||
}
|
||||
let mut text = PageText::default();
|
||||
for (l, line) in (0u32..).zip(lines) {
|
||||
for _ in line.chars() {
|
||||
text.line_of.push(l);
|
||||
text.space_before.push(false);
|
||||
}
|
||||
}
|
||||
text.glyphs = glyphs;
|
||||
text
|
||||
}
|
||||
|
||||
/// Text turned a quarter either way, or upside down, runs on between lines as horizontal text
|
||||
/// does: the line after is the next one across the turned lines, starting back at the turned
|
||||
/// line's head.
|
||||
#[test]
|
||||
fn a_chinese_or_japanese_word_divided_in_turned_text_is_found_on_both_lines() {
|
||||
for direction in [[0.0, -1.0], [0.0, 1.0], [-1.0, 0.0]] {
|
||||
let text = turned(&["装置の検知", "機構です"], 10.0, 15.0, direction);
|
||||
assert_eq!(text.find("検知機構"), vec![3..7], "{direction:?}");
|
||||
assert!(text.find("検知 機構").is_empty(), "{direction:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The line end after a soft hyphen is found past other invisible characters that follow it, in
|
||||
/// the same glyph or in glyphs of their own.
|
||||
#[test]
|
||||
fn a_soft_hyphen_line_end_joins_past_invisible_characters() {
|
||||
let hyphen = laid_out(&[(10.0, 10.0, "Mitar\u{AD}\u{200B}"), (10.0, 25.0, "beiterinnen")], 10.0);
|
||||
assert_eq!(hyphen.find("Mitarbeiterinnen"), vec![0..18]);
|
||||
let mut text = page(&["Mitar\u{AD}", "\u{2060}", "beiterinnen"]);
|
||||
text.line_of = vec![0, 0, 1];
|
||||
assert_eq!(text.find("Mitarbeiterinnen"), vec![0..3]);
|
||||
}
|
||||
|
||||
/// A variation selector after the last Chinese or Japanese character of a line only picks its
|
||||
/// glyph: the line still runs on to the next.
|
||||
#[test]
|
||||
fn a_variation_selector_at_a_line_end_keeps_the_line_running_on() {
|
||||
let selector = laid_out(&[(10.0, 10.0, "葛\u{E0100}"), (10.0, 25.0, "飾区")], 10.0);
|
||||
assert_eq!(selector.find("葛\u{E0100}飾区"), vec![0..4]);
|
||||
assert!(selector.find("葛\u{E0100} 飾区").is_empty());
|
||||
}
|
||||
|
||||
/// A wrapped line is near the one before: a block far down the page keeps its word break.
|
||||
#[test]
|
||||
fn a_line_end_does_not_join_a_block_far_below() {
|
||||
let far = laid_out(&[(10.0, 10.0, "検知"), (10.0, 200.0, "機構")], 10.0);
|
||||
assert!(far.find("検知機構").is_empty());
|
||||
assert_eq!(far.find("検知 機構"), vec![0..4]);
|
||||
}
|
||||
|
||||
/// A wrapped line runs the same way as the one before: a line in another writing direction keeps
|
||||
/// its word break, wherever it sits.
|
||||
#[test]
|
||||
fn a_line_end_does_not_join_a_line_in_another_direction() {
|
||||
let mut across = laid_out(&[(10.0, 10.0, "検知"), (10.0, 25.0, "機構")], 10.0);
|
||||
for g in across.glyphs.iter_mut().skip(2) {
|
||||
g.direction = [0.0, 1.0];
|
||||
}
|
||||
assert!(across.find("検知機構").is_empty());
|
||||
assert_eq!(across.find("検知 機構"), vec![0..4]);
|
||||
}
|
||||
|
||||
/// A line of one character starts level with the one before, not behind it, and still runs on.
|
||||
#[test]
|
||||
fn lines_of_one_character_run_on() {
|
||||
let single = laid_out(&[(10.0, 10.0, "検"), (10.0, 25.0, "知"), (10.0, 40.0, "機")], 10.0);
|
||||
assert_eq!(single.find("検知機"), vec![0..3]);
|
||||
}
|
||||
|
||||
/// A minimal PDF of `objects` (numbered from 1), each with its xref entry.
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The same vertical Japanese as [`a_word_divided_between_vertical_columns_is_found_in_both`],
|
||||
/// drawn by a page in an Identity-V font: extracted text runs on from the foot of the right
|
||||
/// column to the head of the left one.
|
||||
#[test]
|
||||
fn a_word_divided_between_vertical_columns_of_a_page_is_found_in_both() {
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
let chars: Vec<char> = "装置の検知機構です".chars().collect();
|
||||
let pairs: String = (1u32..).zip(&chars).map(|(code, c)| format!("<{code:04X}> <{:04X}> ", *c as u32)).collect();
|
||||
let to_unicode = format!(
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /TestUnicode def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange {} beginbfchar {pairs}endbfchar endcmap CMapName currentdict /CMap defineresource pop end end",
|
||||
chars.len()
|
||||
);
|
||||
let content = "BT /F1 10 Tf 1 0 0 1 300 700 Tm <0001000200030004 0005> Tj 1 0 0 1 285 700 Tm <0006000700080009> Tj ET";
|
||||
let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len());
|
||||
let bytes = pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 800] /Resources << /Font << /F1 4 0 R >> >> /Contents 7 0 R >>".into(),
|
||||
"<< /Type /Font /Subtype /Type0 /BaseFont /TestMincho /Encoding /Identity-V /DescendantFonts [5 0 R] /ToUnicode 6 0 R >>".into(),
|
||||
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /TestMincho /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 1000 /CIDToGIDMap /Identity >>".into(),
|
||||
stream("", &to_unicode),
|
||||
stream("", content),
|
||||
]);
|
||||
let mut renderer = PageRenderer::new(std::sync::Arc::new(bytes), RenderConfig::default());
|
||||
let result = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() });
|
||||
assert!(result.error.is_none(), "{:?}", result.error);
|
||||
let text = result.text.expect("text");
|
||||
assert_eq!(text.find("検知機構"), vec![3..7], "{:?}", text.plain_text());
|
||||
assert_eq!(text.line_rects(3..7).len(), 2, "one highlight per column");
|
||||
assert!(text.find("検知 機構").is_empty());
|
||||
}
|
||||
|
||||
/// A line of Chinese or Japanese that ends in a zero width space drawn with no height still runs
|
||||
/// on: the break is measured on the glyphs that draw the characters either side of it.
|
||||
#[test]
|
||||
fn a_line_end_after_an_invisible_glyph_with_no_height_still_joins() {
|
||||
let mut text = laid_out(&[(10.0, 10.0, "検知\u{200B}"), (10.0, 25.0, "機構")], 10.0);
|
||||
if let Some(g) = text.glyphs.get_mut(2) {
|
||||
g.rect = [30.0, 15.0, 30.0, 15.0];
|
||||
g.quad = [[30.0, 15.0]; 4];
|
||||
}
|
||||
assert_eq!(text.find("検知機構"), vec![0..5]);
|
||||
}
|
||||
|
||||
/// A long run of variation selectors, each a glyph of its own, is searched in time linear in the
|
||||
/// page: the line-end check doesn't rescan the selectors before every glyph.
|
||||
#[test]
|
||||
fn a_long_run_of_variation_selectors_is_searched_quickly() {
|
||||
let selectors: String = std::iter::once('葛').chain(std::iter::repeat_n('\u{FE00}', 200_000)).collect();
|
||||
let text = laid_out(&[(10.0, 10.0, &selectors)], 0.001);
|
||||
let started = std::time::Instant::now();
|
||||
assert!(text.find("飾").is_empty());
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// A variation selector drawn as a glyph of its own with no height at a line end stays in the
|
||||
/// searchable text, and the line still runs on: the break is measured on the letter it follows.
|
||||
#[test]
|
||||
fn a_line_end_after_a_zero_height_variation_selector_glyph_still_joins() {
|
||||
let mut text = laid_out(&[(10.0, 10.0, "葛\u{E0100}"), (10.0, 25.0, "飾区")], 10.0);
|
||||
if let Some(g) = text.glyphs.get_mut(1) {
|
||||
g.rect = [20.0, 15.0, 20.0, 15.0];
|
||||
g.quad = [[20.0, 15.0]; 4];
|
||||
}
|
||||
assert_eq!(text.find("葛\u{E0100}飾区"), vec![0..4]);
|
||||
}
|
||||
|
||||
/// The visible glyphs either side of a break are found however many invisible glyphs stand
|
||||
/// between them and the break.
|
||||
#[test]
|
||||
fn a_line_end_after_many_invisible_glyphs_still_joins() {
|
||||
let line: String = std::iter::once('検').chain(std::iter::once('知')).chain(std::iter::repeat_n('\u{200B}', 40)).collect();
|
||||
let mut text = laid_out(&[(10.0, 10.0, &line), (10.0, 25.0, "機構")], 10.0);
|
||||
for g in text.glyphs.iter_mut().skip(2).take(40) {
|
||||
let [x, y] = [g.rect[0], g.rect[1]];
|
||||
g.rect = [x, y, x, y];
|
||||
g.quad = [[x, y]; 4];
|
||||
}
|
||||
assert_eq!(text.find("検知機構"), vec![0..44]);
|
||||
}
|
||||
|
||||
/// The reference search and `find` agree on a soft hyphen followed, before the line end, by an
|
||||
/// empty glyph and an invisible one: both join the word.
|
||||
#[test]
|
||||
fn reference_and_find_join_past_empty_and_invisible_glyphs_after_a_soft_hyphen() {
|
||||
let mut text = page(&["Mitar\u{AD}", "", "\u{200B}", "beiterinnen"]);
|
||||
text.line_of = vec![0, 0, 0, 1];
|
||||
assert_eq!(text.find("Mitarbeiterinnen"), vec![0..4]);
|
||||
assert_eq!(reference(&text, "Mitarbeiterinnen", false, false, usize::MAX), vec![0..4]);
|
||||
|
||||
// A visible glyph after the soft hyphen ends the hyphenated word there: the line end stays a space.
|
||||
let mut text = page(&["Mitar\u{AD}", "x", "", "\u{200B}", "beiterinnen"]);
|
||||
text.line_of = vec![0, 0, 0, 0, 1];
|
||||
assert!(text.find("Mitarbeiterinnen").is_empty());
|
||||
assert!(reference(&text, "Mitarbeiterinnen", false, false, usize::MAX).is_empty());
|
||||
assert_eq!(text.find("Mitarx beiterinnen"), vec![0..5]);
|
||||
assert_eq!(reference(&text, "Mitarx beiterinnen", false, false, usize::MAX), vec![0..5]);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! A contributor-original synthetic page: a wide stroke of a path built with `y` (the second
|
||||
//! control point on the end point, ISO 32000-2 §8.5.2.1 Table 58) was not drawn at all at some widths,
|
||||
//! because the stroker put NaN into its outline and the rasterizer dropped the whole path. A file
|
||||
//! in the PDFBox issue tracker lost the 32 pt outline of its "XX" this way.
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A one-page 200 × 200 pt PDF drawing `content`, with a cross-reference table.
|
||||
fn pdf(content: &str) -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The red channel of the pixel at (`x`, `y`) pt from the top left of `content` rendered at 1×.
|
||||
fn red_at(content: &str, x: usize, y: usize) -> u8 {
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf(content)), RenderConfig::default());
|
||||
let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
let at = (y * out.width as usize + x) * 4;
|
||||
*out.rgba.get(at).expect("the pixel lies on the page")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_stroke_of_a_path_with_y_curves_is_drawn() {
|
||||
// The path's exact coordinates are the trigger; `cm` moves the point where its two curves
|
||||
// meet, (18.9, 236.3), to about (98.9, 99.7) from the top left, inside every stroke width
|
||||
// below whatever the caps. The `v` path is the same two curves run backwards, a guard that
|
||||
// draws with or without the fix.
|
||||
let y = "21.4166 234.5928 m 19.9009 236.0303 18.9478 236.3428 y 19.2603 238.4209 20.8697 238.4209 y S";
|
||||
let v = "20.8697 238.4209 m 19.2603 238.4209 18.9478 236.3428 v 19.9009 236.0303 21.4166 234.5928 v S";
|
||||
let mut dropped = Vec::new();
|
||||
for path in [y, v] {
|
||||
for width in [4, 8, 16, 24, 32, 48, 63, 64] {
|
||||
for (join, cap) in [(0, 0), (1, 1), (2, 2)] {
|
||||
let content = format!("0 1 0 RG {width} w {join} j {cap} J 1 0 0 1 80 -136 cm {path}");
|
||||
let red = red_at(&content, 98, 99);
|
||||
if red > 64 {
|
||||
dropped.push(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(dropped.is_empty(), "strokes not drawn: {dropped:#?}");
|
||||
}
|
||||
Reference in new issue
Block a user