Export to Word/HTML/RTF: read text and images drawn by form XObjects (fixes #314) (#406)

* 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:
BooksandBrandon Thomas authored and GitHub committed 2026-10-09 18:22:07 -04:00
1 parent 72d640f9f0
commit e7a15783f3
6 files changed
+449 -12

No files matched your search

+74
View File
@@ -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 {
+2 -2
View File
@@ -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};
+145
View File
@@ -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
View File
@@ -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);
+7 -7
View File
@@ -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();
+28
View File
@@ -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.