mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
fix(render): keep link addresses and attachment text within 16 MiB
The link and attachment listings copied a link's web address and an attachment's name and description for every annotation or name-tree entry, so many entries sharing one long string multiplied it in memory. Together they keep at most 16 MiB of that text, and a string past what is left is not converted. A link past it lists as a URI action without its address, an attachment under its generic name without a description.
This commit is contained in:
@@ -42,6 +42,12 @@ 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.
|
||||
const MAX_ANNOTATION_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Web-address text the link listing, and name and description 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, and an attachment
|
||||
/// under its generic name without a description.
|
||||
const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20;
|
||||
|
||||
/// The bytes a listed annotation keeps.
|
||||
fn retained(a: &Annotation) -> usize {
|
||||
let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent];
|
||||
@@ -694,7 +700,8 @@ impl<'a> Inspector<'a> {
|
||||
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);
|
||||
}
|
||||
self.annotations(info);
|
||||
let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES;
|
||||
self.annotations(info, &mut text_budget);
|
||||
self.transitions(info);
|
||||
let mut field_seen = HashSet::new();
|
||||
if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o))
|
||||
@@ -739,7 +746,7 @@ impl<'a> Inspector<'a> {
|
||||
if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) {
|
||||
let mut seen = HashSet::new();
|
||||
for (name, spec) in self.name_tree(ef, &mut seen, 0) {
|
||||
info.attachments.push(self.attachment(name.clone(), spec, AttachmentSource::Document { key: name }));
|
||||
info.attachments.push(self.attachment(name.clone(), spec, AttachmentSource::Document { key: name }, &mut text_budget));
|
||||
}
|
||||
}
|
||||
info.has_javascript |= names.get(b"JavaScript").is_ok();
|
||||
@@ -852,6 +859,18 @@ impl<'a> Inspector<'a> {
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
/// [`Self::text`] when it fits in `budget`, which it then spends. A string longer than the
|
||||
/// budget is not converted at all.
|
||||
fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option<String> {
|
||||
match self.resolve(d.get(key).ok()?) {
|
||||
Object::String(bytes, _) | Object::Name(bytes) if bytes.len() <= *budget => {}
|
||||
_ => return None,
|
||||
}
|
||||
let text = self.text(d, key)?;
|
||||
*budget = budget.checked_sub(text.len())?;
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn name(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
|
||||
self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned())
|
||||
}
|
||||
@@ -1165,7 +1184,8 @@ impl<'a> Inspector<'a> {
|
||||
|
||||
// ── annotations ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn annotations(&self, info: &mut DocInfo) {
|
||||
/// `text_budget` is what is left of [`MAX_LINK_ATTACHMENT_TEXT_BYTES`].
|
||||
fn annotations(&self, info: &mut DocInfo, text_budget: &mut usize) {
|
||||
let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect();
|
||||
pages.sort_unstable_by_key(|&(_, page)| page);
|
||||
// An annotation, or a whole `/Annots` array, that several entries or pages list is on the
|
||||
@@ -1197,11 +1217,11 @@ impl<'a> Inspector<'a> {
|
||||
if subtype == "FileAttachment"
|
||||
&& let Ok(fs) = d.get(b"FS")
|
||||
{
|
||||
let name = self.text(d, b"Contents").unwrap_or_else(|| "attachment".into());
|
||||
info.attachments.push(self.attachment(name, fs, AttachmentSource::Annotation { page, index }));
|
||||
let name = self.budgeted_text(d, b"Contents", text_budget).unwrap_or_else(|| "attachment".into());
|
||||
info.attachments.push(self.attachment(name, fs, AttachmentSource::Annotation { page, index }, text_budget));
|
||||
}
|
||||
if subtype == "Link" {
|
||||
if let Some(link) = self.link(page, index, d) {
|
||||
if let Some(link) = self.link(page, index, d, text_budget) {
|
||||
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
|
||||
info.has_javascript = true;
|
||||
}
|
||||
@@ -1289,7 +1309,7 @@ impl<'a> Inspector<'a> {
|
||||
info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
|
||||
}
|
||||
|
||||
fn link(&self, page: usize, index: usize, d: &Dictionary) -> Option<Link> {
|
||||
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)?;
|
||||
@@ -1302,7 +1322,15 @@ impl<'a> Inspector<'a> {
|
||||
LinkTarget::Page(page, view)
|
||||
}
|
||||
Some("URI") => {
|
||||
LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?)
|
||||
let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?;
|
||||
let uri = (uri.len() <= *text_budget).then(|| String::from_utf8_lossy(uri).into_owned());
|
||||
match uri.filter(|u| u.len() <= *text_budget) {
|
||||
Some(uri) => {
|
||||
*text_budget = text_budget.saturating_sub(uri.len());
|
||||
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())),
|
||||
@@ -1633,7 +1661,7 @@ impl<'a> Inspector<'a> {
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
}
|
||||
|
||||
fn attachment(&self, name: String, spec: &Object, source: AttachmentSource) -> Attachment {
|
||||
fn attachment(&self, name: String, spec: &Object, source: AttachmentSource, text_budget: &mut usize) -> Attachment {
|
||||
let d = self.dict(spec);
|
||||
let size =
|
||||
d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then(
|
||||
@@ -1650,8 +1678,8 @@ impl<'a> Inspector<'a> {
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
let display = d.and_then(|d| self.text(d, b"UF").or_else(|| self.text(d, b"F"))).unwrap_or(name);
|
||||
Attachment { name: display, description: d.and_then(|d| self.text(d, b"Desc")), size, source }
|
||||
let display = d.and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget))).unwrap_or(name);
|
||||
Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2690,6 +2718,36 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
/// Links and attachments sharing one long string each keep a copy only within
|
||||
/// `MAX_LINK_ATTACHMENT_TEXT_BYTES`, and every one is still listed.
|
||||
#[test]
|
||||
fn links_and_attachments_sharing_a_long_string_keep_it_within_a_budget() {
|
||||
const EACH: usize = 20;
|
||||
let links = "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >> ".repeat(EACH);
|
||||
let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS 7 0 R >> ".repeat(EACH);
|
||||
let 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 200 200] /Annots [{links} {files}] >> endobj\n\
|
||||
5 0 obj << /S /URI /URI 6 0 R >> endobj\n\
|
||||
6 0 obj ({}) endobj\n\
|
||||
7 0 obj << /Type /Filespec /F (a.txt) /Desc 6 0 R >> endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF",
|
||||
"x".repeat(1 << 20),
|
||||
);
|
||||
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);
|
||||
assert_eq!(info.links.len(), EACH);
|
||||
assert_eq!(info.attachments.len(), EACH);
|
||||
let uris = info.links.iter().map(|l| if let LinkTarget::Uri(u) = &l.target { u.len() } else { 0 });
|
||||
let descriptions = info.attachments.iter().map(|a| a.description.as_ref().map_or(0, String::len));
|
||||
let kept: usize = uris.chain(descriptions).sum();
|
||||
assert!(kept > 0 && kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} bytes kept");
|
||||
assert!(info.links.iter().any(|l| matches!(&l.target, LinkTarget::Other(s) if s == "URI")));
|
||||
}
|
||||
|
||||
/// Links and attachments after the comment budget fills are still listed, and a JavaScript link
|
||||
/// among them still marks the document.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user