mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
* Export to Word/HTML/RTF: read text and images drawn by form XObjects (fixes #314) The office export built its paragraphs from text_blocks and its images from page_images. Both read only the page's own content streams (they back Edit text and Edit image, which rewrite those streams), so a page drawn through `/Fm1 Do`, as many print-to-PDF tools emit, exported as an empty document. Add read-only reading_blocks and reading_images to pdfcraft-edit. They follow Do into form XObjects (nested ones too), apply each form's /Matrix, use its own /Resources (or the caller's when it has none), and start it from the graphics state at the Do. Depth is capped at MAX_FORM_DEPTH and a form already being read is skipped, so self-referencing and mutually recursive forms are read once. export_pages uses them; editing is unchanged and still offers only the page's own text and images. * Export: a page's walk through form XObjects has a total visit budget, so forms that fan out can't hang it * Export: keep the image walk within clippy's argument limit --------- Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
1 parent
72d640f9f0
commit
e7a15783f3
6 files changed
+449
-12
No files matched your search
@@ -142,6 +142,80 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The images page `page` (0-based) shows, in drawing order, including those drawn inside form
|
||||
/// XObjects (Export to Word, HTML and RTF). Read-only: indexes into this list are not
|
||||
/// [`page_images`] indexes, so they can't be passed to [`change_image`].
|
||||
pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditError> {
|
||||
/// The CTM and `q` stack carry from one page stream to the next (§7.8.2); a form starts
|
||||
/// its own.
|
||||
/// The forms being read (cycle guard) and how many were entered (the page's budget).
|
||||
struct Forms {
|
||||
path: Vec<ObjRef>,
|
||||
visits: usize,
|
||||
}
|
||||
struct State {
|
||||
ctm: Matrix,
|
||||
stack: Vec<Matrix>,
|
||||
}
|
||||
fn walk(doc: &Document, data: &[u8], resources: &Dict, st: &mut State, stream: usize, forms: &mut Forms, out: &mut Vec<PageImage>) {
|
||||
let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let (ctm, stack) = (&mut st.ctm, &mut st.stack);
|
||||
for (i, op) in parse(data).ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(*ctm),
|
||||
b"Q" => *ctm = stack.pop().unwrap_or(*ctm),
|
||||
b"cm" => {
|
||||
if let Some(m) = op.nums::<6>() {
|
||||
*ctm = Matrix(m).then(ctm);
|
||||
}
|
||||
}
|
||||
b"Do" => {
|
||||
let Some(name) = op.name(0) else { continue };
|
||||
if let Some(form) = crate::text::form_call(doc, resources, name, *ctm, &forms.path, &mut forms.visits) {
|
||||
if let Some(r) = form.obj {
|
||||
forms.path.push(r);
|
||||
}
|
||||
let mut inner = State { ctm: form.ctm, stack: Vec::new() };
|
||||
walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out);
|
||||
if form.obj.is_some() {
|
||||
forms.path.pop();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue };
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
if s.dict.name(b"Subtype") != Some(b"Image") {
|
||||
continue;
|
||||
}
|
||||
out.push(PageImage {
|
||||
rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]),
|
||||
matrix: ctm.0,
|
||||
name: String::from_utf8_lossy(name).into_owned(),
|
||||
object: Some(r),
|
||||
is_form: false,
|
||||
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
|
||||
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
|
||||
stream,
|
||||
op: i,
|
||||
draw_matrix: ctm.0,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let p = page_of(doc, page)?;
|
||||
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
let mut st = State { ctm: Matrix::IDENTITY, stack: Vec::new() };
|
||||
let mut forms = Forms { path: Vec::new(), visits: 0 };
|
||||
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
|
||||
walk(doc, &data, &res, &mut st, si, &mut forms, &mut out);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What to do with an image.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ImageChange {
|
||||
|
||||
@@ -614,9 +614,9 @@ pub fn marks_present(doc: &Document) -> Vec<MarkKind> {
|
||||
mod flatten;
|
||||
pub use flatten::{flatten, flatten_fill_sign};
|
||||
pub mod images;
|
||||
pub use images::{ImageChange, PageImage, change_image, page_images, rect_to_rect, turn_about_centre};
|
||||
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre};
|
||||
pub mod text;
|
||||
pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, replace_block, replace_line, rewrite_block, text_blocks, text_lines};
|
||||
pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, text_lines};
|
||||
pub mod added;
|
||||
pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, first_undrawable, list_added, update_content};
|
||||
|
||||
|
||||
@@ -1429,3 +1429,148 @@ fn fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0()
|
||||
assert!(summaries(&doc).iter().any(|s| s.intent.as_deref() == Some("FreeTextTypeWriter")), "the hidden typewriter stays");
|
||||
assert_eq!(summaries(&doc).iter().filter(|s| s.intent.as_deref() == Some("FreeTextTypeWriter")).count(), 1);
|
||||
}
|
||||
|
||||
/// A one-page document from its objects (object 1 is the catalog).
|
||||
fn build(objs: &[&str]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
fn stream(dict: &str, data: &str) -> String {
|
||||
format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len())
|
||||
}
|
||||
|
||||
/// #314: text and images drawn by (nested) form XObjects are read for export, placed through
|
||||
/// every form matrix and set in each form's own fonts; editing still sees only the page's text.
|
||||
#[test]
|
||||
fn reading_follows_form_xobjects() {
|
||||
let page = stream("", "q 1 0 0 1 10 0 cm /Fm1 Do Q BT /F1 12 Tf 72 100 Td (Page text) Tj ET");
|
||||
let fm1 = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 600 800] /Matrix [1 0 0 1 0 50] /Resources << /Font << /F1 7 0 R >> /XObject << /Fm2 8 0 R >> >>",
|
||||
"BT /F1 18 Tf 72 700 Td (Hello from a test invoice) Tj ET /Fm2 Do",
|
||||
);
|
||||
let fm2 = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 300 400] /Matrix [2 0 0 2 0 0] /Resources << /Font << /F2 9 0 R >> /XObject << /Im1 10 0 R >> >>",
|
||||
"BT /F2 10 Tf 50 100 Td (Nested) Tj ET q 20 0 0 10 5 5 cm /Im1 Do Q",
|
||||
);
|
||||
let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A");
|
||||
let doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> >> /Contents 4 0 R >>",
|
||||
&page,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>",
|
||||
&fm1,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
&fm2,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>",
|
||||
&image,
|
||||
]);
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 0.01;
|
||||
|
||||
let blocks = text::reading_blocks(&doc, 0).unwrap();
|
||||
let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect();
|
||||
assert_eq!(texts, ["Hello from a test invoice", "Nested", "Page text"]);
|
||||
let (hello, nested, own) = (&blocks[0], &blocks[1], &blocks[2]);
|
||||
// Hello: (72, 700) moved by the form's (0, 50) and the page's (10, 0).
|
||||
assert_eq!(hello.base_font, "Helvetica", "the form's own /F1, not the page's");
|
||||
assert!(close(hello.rect[0], 82.0) && close(hello.size, 18.0), "{hello:?}");
|
||||
// Nested: (50, 100) doubled by Fm2, then Fm1 and the page: (110, 250), 20 pt.
|
||||
assert_eq!(nested.base_font, "Times-Bold");
|
||||
assert!(nested.bold);
|
||||
assert!(close(nested.rect[0], 110.0) && close(nested.size, 20.0), "{nested:?}");
|
||||
assert_eq!(own.base_font, "Courier");
|
||||
|
||||
let images = images::reading_images(&doc, 0).unwrap();
|
||||
assert_eq!(images.len(), 1, "{images:?}");
|
||||
let r = images[0].rect;
|
||||
assert!(close(r[0], 20.0) && close(r[1], 60.0) && close(r[2], 60.0) && close(r[3], 80.0), "{r:?}");
|
||||
assert_eq!((images[0].width, images[0].height), (1, 1));
|
||||
|
||||
// Editing works on the page's own streams only, unchanged.
|
||||
let editable: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(editable, ["Page text"]);
|
||||
// The page draws one figure of its own (Fm1, edited as a whole); the image inside Fm2 is not
|
||||
// a page image.
|
||||
let own = images::page_images(&doc, 0).unwrap();
|
||||
assert_eq!(own.len(), 1, "{own:?}");
|
||||
assert!(own[0].is_form && own[0].object == Some(pdfcraft_cos::ObjRef::new(6, 0)), "{own:?}");
|
||||
}
|
||||
|
||||
/// A chain of forms, each drawing the next twenty times, twelve deep: within the depth cap but
|
||||
/// 20^11 visits without a budget. Reading stops at the page's visit budget instead of hanging.
|
||||
#[test]
|
||||
fn form_xobjects_that_fan_out_stop_at_the_visit_budget() {
|
||||
let font = "/Font << /F1 5 0 R >>";
|
||||
let levels = 12;
|
||||
let mut objs = vec![
|
||||
"<< /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 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /N 6 0 R >> >> /Contents 4 0 R >>"
|
||||
.to_string(),
|
||||
stream("", "/N Do"),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
];
|
||||
for level in 0..levels {
|
||||
let next = 7 + level;
|
||||
objs.push(if level + 1 == levels {
|
||||
stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} >>"), "BT /F1 10 Tf 10 10 Td (leaf) Tj ET")
|
||||
} else {
|
||||
stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /N {next} 0 R >> >>"), &"/N Do ".repeat(20))
|
||||
});
|
||||
}
|
||||
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||||
let doc = build(&refs);
|
||||
let start = std::time::Instant::now();
|
||||
let _ = text::reading_blocks(&doc, 0).unwrap();
|
||||
assert!(images::reading_images(&doc, 0).unwrap().is_empty());
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed());
|
||||
}
|
||||
|
||||
/// Forms that draw themselves or each other, have no resources, a malformed matrix, or are
|
||||
/// missing are read once (or skipped) without panicking or looping.
|
||||
#[test]
|
||||
fn hostile_form_xobjects_are_read_once() {
|
||||
let page = stream("", "/Loop Do /A Do /NoRes Do /Bad Do /Missing Do /F1 Do");
|
||||
let font = "/Font << /F1 5 0 R >>";
|
||||
let lp = stream(
|
||||
&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /Loop 6 0 R >> >>"),
|
||||
"/Loop Do BT /F1 10 Tf 10 10 Td (once) Tj ET",
|
||||
);
|
||||
let a = stream(
|
||||
&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /B 8 0 R >> >>"),
|
||||
"/B Do BT /F1 10 Tf 10 100 Td (from a) Tj ET",
|
||||
);
|
||||
let b = stream(
|
||||
&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /A 7 0 R >> >>"),
|
||||
"/A Do BT /F1 10 Tf 10 200 Td (from b) Tj ET",
|
||||
);
|
||||
let nores = stream("/Subtype /Form /BBox [0 0 9 9]", "BT /F1 10 Tf 10 300 Td (inherited) Tj ET");
|
||||
let bad = stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Matrix [1 0 0] /Resources << {font} >>"), "BT /F1 10 Tf 10 400 Td (bad matrix) Tj ET");
|
||||
let doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /Loop 6 0 R /A 7 0 R /NoRes 9 0 R /Bad 10 0 R >> >> /Contents 4 0 R >>",
|
||||
&page,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
&lp,
|
||||
&a,
|
||||
&b,
|
||||
&nores,
|
||||
&bad,
|
||||
]);
|
||||
let texts: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["once", "from b", "from a", "inherited", "bad matrix"]);
|
||||
assert!(images::reading_images(&doc, 0).unwrap().is_empty());
|
||||
}
|
||||
+193
-3
@@ -13,7 +13,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, serialize_ops};
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops};
|
||||
use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream};
|
||||
use pdfcraft_fonts::pdf::Metrics;
|
||||
use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from};
|
||||
@@ -295,7 +295,17 @@ impl Carry {
|
||||
}
|
||||
}
|
||||
|
||||
fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap<Vec<u8>, Rc<Metrics>>, carry: &mut Carry) -> Vec<Shown> {
|
||||
/// A `Do` met while interpreting: its operator index, XObject name and the state there.
|
||||
type Invocation = (usize, Vec<u8>, Ts);
|
||||
|
||||
fn interpret(
|
||||
doc: &Document,
|
||||
ops: &[Op],
|
||||
fonts_res: &Dict,
|
||||
cache: &mut HashMap<Vec<u8>, Rc<Metrics>>,
|
||||
carry: &mut Carry,
|
||||
mut invoked: Option<&mut Vec<Invocation>>,
|
||||
) -> Vec<Shown> {
|
||||
let mut out = Vec::new();
|
||||
let mut ts = carry.ts.clone();
|
||||
let mut at_bt = (0usize, ts.clone());
|
||||
@@ -346,6 +356,11 @@ fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap<V
|
||||
b"Tz" => ts.scale = op.num(0).unwrap_or(100.0) / 100.0,
|
||||
b"TL" => ts.leading = op.num(0).unwrap_or(0.0),
|
||||
b"Ts" => ts.rise = op.num(0).unwrap_or(0.0),
|
||||
b"Do" => {
|
||||
if let (Some(list), Some(name)) = (invoked.as_deref_mut(), op.name(0)) {
|
||||
list.push((i, name.to_vec(), ts.clone()));
|
||||
}
|
||||
}
|
||||
b"Td" | b"TD" => {
|
||||
if let Some([x, y]) = op.nums::<2>() {
|
||||
if op.is("TD") {
|
||||
@@ -463,7 +478,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
// joined, so an operator whose operands end one piece still has them.
|
||||
let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry);
|
||||
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None);
|
||||
let mut last: Option<(usize, usize, f64, f64, f64)> = None; // (piece, bt, baseline, end_x, size)
|
||||
for s in shown {
|
||||
// A line belongs to the piece its first operator starts in (lines in different pieces
|
||||
@@ -523,6 +538,173 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// The line `s` continues, or a new one: `last` is the previous run of the same stream
|
||||
/// (`bt`, baseline, end x, size).
|
||||
fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)>, s: Shown, stream: usize) {
|
||||
let joins = last.is_some_and(|(bt, base, end, size)| {
|
||||
bt == s.bt
|
||||
&& lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice())
|
||||
&& lines
|
||||
.last()
|
||||
.is_some_and(|l| (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color)
|
||||
&& (s.baseline - base).abs() < size * 0.3
|
||||
&& s.start_x > end - size
|
||||
&& s.start_x - end < size * 3.0
|
||||
});
|
||||
if joins && let Some(l) = lines.last_mut() {
|
||||
let gap = s.start_x - last.map_or(s.start_x, |x| x.2);
|
||||
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
l.text.push(' ');
|
||||
}
|
||||
l.text.push_str(&s.text);
|
||||
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
|
||||
l.ops.push(s.op);
|
||||
l.decodable &= s.decodable;
|
||||
l.color = fill_color(&s.state.fill);
|
||||
} else {
|
||||
lines.push(TextLine {
|
||||
text: s.text.clone(),
|
||||
rect: s.rect,
|
||||
font: String::from_utf8_lossy(&s.font).into_owned(),
|
||||
base_font: s.base_font.clone(),
|
||||
size: s.size,
|
||||
bold: s.bold,
|
||||
italic: s.italic,
|
||||
color: fill_color(&s.state.fill),
|
||||
decodable: s.decodable,
|
||||
stream,
|
||||
ops: vec![s.op],
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
tlm: s.tlm.0,
|
||||
k: (s.tm.then(&s.state.ctm).0[2].powi(2) + s.tm.then(&s.state.ctm).0[3].powi(2)).sqrt(),
|
||||
ctm: s.state.ctm.0,
|
||||
baseline: s.baseline,
|
||||
x: s.start_x,
|
||||
state: TextState::of(&s.state),
|
||||
bt_op: s.bt_op,
|
||||
bt_state: TextState::of(&s.bt_state),
|
||||
},
|
||||
});
|
||||
}
|
||||
*last = Some((s.bt, s.baseline, s.end_x, s.size.max(1.0)));
|
||||
}
|
||||
|
||||
/// How deep form XObjects nest before reading stops (they may also refer to themselves).
|
||||
pub(crate) const MAX_FORM_DEPTH: usize = 12;
|
||||
|
||||
/// The most form XObjects one page's walk enters, at any depth. The depth cap alone still lets
|
||||
/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 12
|
||||
/// levels), which would hang export on a hostile PDF.
|
||||
pub(crate) const MAX_FORM_VISITS: usize = 4096;
|
||||
|
||||
/// A form XObject a page draws: its object, content, resources and its matrix composed with the
|
||||
/// CTM at the `Do`.
|
||||
pub(crate) struct FormCall {
|
||||
pub(crate) obj: Option<pdfcraft_cos::ObjRef>,
|
||||
pub(crate) data: Vec<u8>,
|
||||
pub(crate) resources: Dict,
|
||||
pub(crate) ctm: Matrix,
|
||||
}
|
||||
|
||||
/// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names
|
||||
/// and forms already being read (`path`), nested too deep, or past the page's `visits` budget
|
||||
/// ([`MAX_FORM_VISITS`]); each form entered counts one visit.
|
||||
pub(crate) fn form_call(
|
||||
doc: &Document,
|
||||
resources: &Dict,
|
||||
name: &[u8],
|
||||
ctm: Matrix,
|
||||
path: &[pdfcraft_cos::ObjRef],
|
||||
visits: &mut usize,
|
||||
) -> Option<FormCall> {
|
||||
if path.len() >= MAX_FORM_DEPTH || *visits >= MAX_FORM_VISITS {
|
||||
return None;
|
||||
}
|
||||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?;
|
||||
let o = xobjects.get(name)?;
|
||||
let obj = o.as_ref();
|
||||
if obj.is_some_and(|r| path.contains(&r)) {
|
||||
return None;
|
||||
}
|
||||
let Object::Stream(s) = &*doc.resolve(o) else { return None };
|
||||
if s.dict.name(b"Subtype") != Some(b"Form") {
|
||||
return None;
|
||||
}
|
||||
let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| {
|
||||
let a = m.as_array()?;
|
||||
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
<[f64; 6]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite()))
|
||||
});
|
||||
// A form without its own resources uses the ones it was drawn with (PDF 1.1 files).
|
||||
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
*visits = visits.saturating_add(1);
|
||||
Some(FormCall {
|
||||
obj,
|
||||
data: s.decoded().unwrap_or_default(),
|
||||
resources: own.unwrap_or_else(|| resources.clone()),
|
||||
ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The lines a page shows as it reads, including text drawn by form XObjects (which
|
||||
/// [`text_lines`] leaves out because it can only rewrite the page's own streams). Lines from a
|
||||
/// form carry a stream number past the page's streams, one per form drawn.
|
||||
fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError> {
|
||||
struct Walk<'a> {
|
||||
doc: &'a Document,
|
||||
lines: Vec<TextLine>,
|
||||
next_stream: usize,
|
||||
path: Vec<pdfcraft_cos::ObjRef>,
|
||||
visits: usize,
|
||||
}
|
||||
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: usize) {
|
||||
let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
let mut cache = HashMap::new();
|
||||
let mut invoked = Vec::new();
|
||||
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked));
|
||||
let mut last = None;
|
||||
let mut forms = invoked.into_iter().peekable();
|
||||
for s in shown {
|
||||
while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) {
|
||||
enter(w, resources, &name, ts);
|
||||
// Text after a form starts a new line.
|
||||
last = None;
|
||||
}
|
||||
add_shown(&mut w.lines, &mut last, s, stream);
|
||||
}
|
||||
for (_, name, ts) in forms {
|
||||
enter(w, resources, &name, ts);
|
||||
}
|
||||
}
|
||||
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) {
|
||||
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.visits) else { return };
|
||||
let ops = parse(&form.data).ops;
|
||||
// The form starts with the graphics state at its `Do`, its own matrix applied.
|
||||
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new() };
|
||||
let stream = w.next_stream;
|
||||
w.next_stream = w.next_stream.saturating_add(1);
|
||||
if let Some(r) = form.obj {
|
||||
w.path.push(r);
|
||||
}
|
||||
walk(w, &ops, &form.resources, &mut carry, stream);
|
||||
if form.obj.is_some() {
|
||||
w.path.pop();
|
||||
}
|
||||
}
|
||||
let p = page_dict(doc, page)?;
|
||||
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 };
|
||||
let mut carry = Carry::new();
|
||||
for (si, (_, data)) in streams.into_iter().enumerate() {
|
||||
walk(&mut w, &parse(&data).ops, &res, &mut carry, si);
|
||||
}
|
||||
let mut lines = w.lines;
|
||||
lines.retain(|l| !l.text.trim().is_empty());
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Text → the bytes that show it in the chosen font.
|
||||
type Encoder = Box<dyn Fn(&str) -> Option<Vec<u8>>>;
|
||||
|
||||
@@ -828,6 +1010,14 @@ pub fn text_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditEr
|
||||
Ok(group_blocks(&lines))
|
||||
}
|
||||
|
||||
/// The paragraphs on a page as a reader sees them, including text drawn by form XObjects
|
||||
/// (Export to Word, HTML and RTF). Read-only: indexes into this list are not
|
||||
/// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`].
|
||||
pub fn reading_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditError> {
|
||||
let lines = reading_lines(doc, page)?;
|
||||
Ok(group_blocks(&lines))
|
||||
}
|
||||
|
||||
/// Lines that share a left edge, a centre or a right edge (left, centred or right-aligned text).
|
||||
fn aligned(a: &TextLine, b: &TextLine) -> bool {
|
||||
let tol = b.size.max(1.0);
|
||||
|
||||
@@ -150,7 +150,8 @@ impl OfficeFormat {
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// The pages as paragraphs and images (for Word, HTML and RTF export).
|
||||
/// The pages as paragraphs and images (for Word, HTML and RTF export), including what
|
||||
/// form XObjects draw.
|
||||
pub fn export_pages(&self) -> Vec<pdfcraft_export::Page> {
|
||||
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return Vec::new() };
|
||||
self.info
|
||||
@@ -158,7 +159,7 @@ impl crate::Document {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, info)| {
|
||||
let blocks = pdfcraft_edit::text_blocks(cos, i)
|
||||
let blocks = pdfcraft_edit::reading_blocks(cos, i)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|b| !b.text.trim().is_empty())
|
||||
@@ -174,12 +175,11 @@ impl crate::Document {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let images = self
|
||||
.page_images(i)
|
||||
let images = pdfcraft_edit::reading_images(cos, i)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, im)| {
|
||||
let (ext, bytes) = self.page_image_file(i, k).ok()?;
|
||||
.filter_map(|im| {
|
||||
let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?;
|
||||
Some(pdfcraft_export::Image { ext: if ext == "jpg" { "jpg" } else { "png" }, bytes, rect: im.rect })
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1761,6 +1761,34 @@ fn exporting_office_files_keeps_images() {
|
||||
assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("data:image/png;base64,"));
|
||||
}
|
||||
|
||||
/// #314: a page whose content is drawn through a form XObject exports its text (the file from
|
||||
/// the report: no xref table, wrong stream lengths).
|
||||
#[test]
|
||||
fn exporting_office_files_reads_form_xobjects() {
|
||||
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>> endobj
|
||||
3 0 obj <</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Resources<</XObject<</Fm1 7 0 R>>>>/Contents 5 0 R>> endobj
|
||||
4 0 obj <</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>> endobj
|
||||
5 0 obj <</Length 22>> stream
|
||||
q 1 0 0 1 0 0 cm /Fm1 Do Q
|
||||
endstream endobj
|
||||
7 0 obj <</Type/XObject/Subtype/Form/BBox[0 0 595 842]/Resources<</Font<</F1 4 0 R>>>>/Length 60>> stream
|
||||
BT /F1 18 Tf 72 760 Td (Hello from a test invoice) Tj ET
|
||||
endstream endobj
|
||||
trailer <</Root 1 0 R>>
|
||||
%%EOF
|
||||
";
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(pdf.to_vec()), None).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
let blocks: Vec<String> = d.export_pages()[0].blocks.iter().map(|b| b.text.clone()).collect();
|
||||
assert_eq!(blocks, ["Hello from a test invoice"]);
|
||||
assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("Hello from a test invoice"));
|
||||
// Edit text still offers only what the page's own streams show.
|
||||
assert!(d.text_blocks(0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_office_files_keeps_text_colour() {
|
||||
// #526: a dark green heading came out black in Word.
|
||||
|
||||
Reference in new issue
Block a user