idml: read PDFAttribute PageNumber so placed PDFs show their page

A placement of a multi-page PDF carried no page number through IDML: graphic()
always made the asset with page 0 and keyed linked_assets by path alone, so
every placement of one file showed page 1 (InDesign's PDFAttribute PageNumber
was ignored). Import now reads PDFAttribute/@PageNumber (1-based) into
Asset.page, linked_assets is keyed by (path, page), and export writes
PDFAttribute back for pages past the first.

Regression test: the same file placed at pages 2, 1 and 2 imports as two
assets with pages 1 and 0 and round-trips through export.
This commit is contained in:
midasmatterhorn
2026-10-14 23:46:19 +03:00
parent d07cbbe94c
commit 8cfaa90dcb
3 changed files with 65 additions and 8 deletions
+5
View File
@@ -1884,6 +1884,11 @@ impl<'a> Ex<'a> {
.attr("ImportPolicy", "NoAutoImport")
.attr("ExportPolicy", "NoAutoExport"),
);
if is_pdf && asset.page > 0 {
// PDF Import Options: the page is 1-based; page 1 (the default) needs no element, as
// InDesign omits it too.
el.push(El::new("PDFAttribute").attr("PageNumber", asset.page.saturating_add(1).to_string()));
}
el
}
+20 -8
View File
@@ -194,8 +194,9 @@ struct Importer<'r> {
page_index: HashMap<String, usize>,
item_ids: HashMap<String, ItemId>,
assets: BTreeMap<AssetId, Arc<Asset>>,
/// Assets read from a link path, so later placements of that path reuse them unread.
linked_assets: HashMap<String, AssetId>,
/// Assets read from a link path at a page, so later placements of that path and page reuse
/// them unread.
linked_assets: HashMap<(String, u32), AssetId>,
ctx: ItemCtx,
sections: Vec<Section>,
footnote_options: designcraft_doc::FootnoteOptions,
@@ -2377,17 +2378,28 @@ impl<'r> Importer<'r> {
})
};
let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default();
// PDF Import Options: `PDFAttribute/@PageNumber` is 1-based; `Asset.page` is 0-based. A
// missing or malformed value means page 1, and an out-of-range page later fails to render
// rather than panicking.
let page = g
.find("PDFAttribute")
.or_else(|| g.prop_el("PDFAttribute"))
.and_then(|a| a.get("PageNumber"))
.and_then(|v| v.trim().parse::<u64>().ok())
.and_then(|n| u32::try_from(n.saturating_sub(1)).ok())
.unwrap_or(0);
let mut read_from = None;
if data.is_empty()
&& let Some(p) = &uri
{
// Every placement of a linked file shares one asset; read the file once.
if let Some(id) = self.linked_assets.get(p) {
// Every placement of a linked file at one page shares one asset; read the file once.
let key = (p.clone(), page);
if let Some(id) = self.linked_assets.get(&key) {
return graphic(*id);
}
if let Some(d) = (self.read_link)(p) {
data = d;
read_from = Some(p.clone());
read_from = Some(key);
}
}
let (mime, px) = sniff_image(&data);
@@ -2407,9 +2419,9 @@ impl<'r> Importer<'r> {
let fresh = AssetId(self.alloc());
let link_path = uri.filter(|p| p.contains('/') || p.contains('\\'));
let id =
designcraft_doc::insert_asset(&mut self.assets, Asset { page: 0, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels });
if let Some(p) = read_from {
self.linked_assets.insert(p, id);
designcraft_doc::insert_asset(&mut self.assets, Asset { page, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels });
if let Some(key) = read_from {
self.linked_assets.insert(key, id);
}
graphic(id)
}
+40
View File
@@ -31,6 +31,22 @@ fn image(n: usize, uri: &str, contents: Option<&[u8]>) -> String {
)
}
/// A graphic frame showing `pdf` (a `<PDF>` element) with the page `PageNumber` (1-based).
fn pdf_frame(n: usize, x: f64, page: u32) -> String {
format!(
r#"<Rectangle Self="r{n}" ItemLayer="L1" ItemTransform="1 0 0 1 {x} 0" ContentType="GraphicType">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 30"/><PathPointType Anchor="40 30"/><PathPointType Anchor="40 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
<PDF Self="im{n}" ItemTransform="1 0 0 1 {n} 0">
<Properties><GraphicBounds Left="0" Top="0" Right="40" Bottom="30"/></Properties>
<Link Self="lk{n}" LinkResourceURI="file:/art/insert.pdf" LinkResourceFormat="$ID/PDF" StoredState="Normal"/>
<PDFAttribute PageNumber="{page}" PDFCrop="CropMedia" TransparentBackground="true"/>
</PDF>
</Rectangle>"#
)
}
fn package(frames: &[String]) -> Vec<u8> {
let spread = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
@@ -108,3 +124,27 @@ fn one_file_placed_several_times_imports_as_one_asset() {
}
}
}
/// `PDFAttribute/@PageNumber` (1-based) is read into `Asset.page` (0-based), and the same file at
/// different pages stays apart (#905).
#[test]
fn a_pdf_placement_reads_its_page_number() {
let bytes = b"%PDF-1.7\n% fixture".to_vec();
let reader = |p: &str| p.ends_with("insert.pdf").then(|| bytes.clone());
let frames = vec![pdf_frame(1, 0.0, 2), pdf_frame(2, 100.0, 1), pdf_frame(3, 200.0, 2)];
let d = import_idml_with(&package(&frames), &reader).unwrap();
let p = placements(&d);
let page = |id| d.assets.get(&id).map(|a| a.page);
assert_eq!(page(p[0].0), Some(1), "PageNumber=\"2\" is the second page, stored 0-based");
assert_eq!(page(p[1].0), Some(0), "PageNumber=\"1\" is the first page");
assert_eq!(p[0].0, p[2].0, "the same file at the same page shares one asset");
assert_ne!(p[0].0, p[1].0, "the same file at another page is another asset");
// Exported (linked or embedded), the page survives the round trip.
for embed_images in [false, true] {
let out = export_idml_with(&d, &ExportOptions { embed_images });
let back = import_idml_with(&out, &reader).unwrap();
let pages: Vec<_> = placements(&back).iter().map(|(id, _, _)| back.assets.get(id).map(|a| a.page)).collect();
assert_eq!(pages, vec![Some(1), Some(0), Some(1)], "embed_images: {embed_images}");
}
}