From 9dd6bd4ff648b20c47426a22a3d19c41dcad3fe5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 21:34:37 -0400 Subject: [PATCH] =?UTF-8?q?A:=20tagged=20PDF=20=E2=80=94=20structure=20tre?= =?UTF-8?q?e,=20figures=20with=20alt=20text,=20artifacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/export.rs | 27 +++++++++++- crates/pdf/src/export.rs | 78 ++++++++++++++++++++++++++++++++- crates/pdf/src/lib.rs | 4 ++ crates/ui-egui/src/menus.rs | 1 + docs/parity.md | 8 ++-- 6 files changed, 112 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8c77e9d87..efe96e109 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,6 +33,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF. - Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed). - File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI. +- Tagged PDF (`file.exportPdf {tagged}`, on by default from the UI): structure tree with stories as paragraphs in reading order, figures carrying their alt text, parent-page items and printer's marks as artifacts. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f18225f93..0f2c6bf28 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -9,7 +9,7 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None, - "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), spreads?: bool, bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", + "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), spreads?: bool, bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", has_doc, export_pdf), cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, "{path?, title?, author?, language?: \"en\"} → {path, bytes} (no path: {base64, bytes})", @@ -115,6 +115,7 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result { compress_images: p.get("compressImages").and_then(Value::as_bool).unwrap_or(false), title: str_param(p, "title").map(str::to_string), author: str_param(p, "author").map(str::to_string), + tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false), ..PdfOptions::default() }) } @@ -163,3 +164,27 @@ mod text_tests { std::fs::remove_dir_all(&dir).ok(); } } + +#[cfg(test)] +mod tagged_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn tagged_pdf_has_structure_and_alt_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 320, 200, 420]})).unwrap(); + s.execute("object.altText", &json!({"text": "A plain box", "ids": [r["id"]]})).unwrap(); + let pdf = |s: &mut Session, tagged: bool| { + let b64 = s.execute("file.exportPdf", &json!({"tagged": tagged})).unwrap()["base64"].as_str().unwrap().to_string(); + String::from_utf8_lossy(&super::super::file::base64_decode(&b64)).into_owned() + }; + let t = pdf(&mut s, true); + assert!(t.contains("/StructTreeRoot") && t.contains("/Figure") && t.contains("A plain box"), "tagged"); + assert!(t.contains("/P") && t.contains("/MarkInfo")); + assert!(!pdf(&mut s, false).contains("/StructTreeRoot")); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 1ac458b71..f1e5b2541 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -18,6 +18,7 @@ use krilla::num::NormalizedF32; use krilla::page::PageSettings; use krilla::paint::{Fill, FillRule, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash}; use krilla::surface::Surface; +use krilla::tagging::{Artifact, ArtifactType, ContentTag, Identifier, SpanTag, Tag, TagGroup, TagTree}; use crate::{ExportReport, PdfError, PdfOptions, Result, Standard}; @@ -151,6 +152,8 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) fonts: HashMap::new(), reverse_cmaps: HashMap::new(), rgb_only: archival.is_some(), + tags: Vec::new(), + story_tags: HashMap::new(), }; if ex.rgb_only { ex.warn("PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)"); @@ -178,7 +181,13 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) ex.spread(&mut s, sh.spread); s.pop(); } + if opts.tagged { + s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Page, None))); + } ex.marks(&mut s, sh, &title, created); + if opts.tagged { + s.end_tagged(); + } s.pop(); s.finish(); for a in crate::links::annotations(doc, cache, &sheets, sheet_idx) { @@ -186,6 +195,9 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) } page.finish(); } + if opts.tagged { + pdf.set_tag_tree(ex.tag_tree()); + } let bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; let mut warnings = ex.warnings; warnings.dedup(); @@ -234,6 +246,14 @@ pub(crate) struct Exporter<'a> { pub reverse_cmaps: HashMap>>, /// Convert CMYK to RGB (PDF/A: krilla needs a CMYK output profile we don't ship yet). pub rgb_only: bool, + /// Tagged PDF: the structure in reading order (stories gather their frames' content). + tags: Vec, + story_tags: HashMap>, +} + +enum TagEntry { + Story(designcraft_doc::StoryId), + Figure(TagGroup), } pub(crate) fn tf(a: Affine) -> Transform { @@ -381,12 +401,12 @@ impl Exporter<'_> { if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 { continue; } - self.item(s, it, Affine::translate((dx, 0.0)), Some(&page_name)); + self.top_item(s, it, Affine::translate((dx, 0.0)), Some(&page_name), true); } } for it in &sp.items { if it.layer == layer.id { - self.item(s, it, Affine::IDENTITY, None); + self.top_item(s, it, Affine::IDENTITY, None, false); } } } @@ -420,6 +440,60 @@ impl Exporter<'_> { } } + /// A spread or parent item, inside its structure tag when tagging. + fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>, on_parent: bool) { + if !self.opts.tagged || it.hidden || it.nonprinting { + return self.item(s, it, parent, page_name); + } + let story = it.text_frame().map(|t| t.story); + let figure = !it.alt_text.is_empty() || matches!(it.content, Content::Graphic(_)) || it.has_nested_items(); + if on_parent || (story.is_none() && !figure) { + // Page furniture and decoration. + let kind = if on_parent { ArtifactType::Page } else { ArtifactType::Other }; + s.start_tagged(ContentTag::Artifact(Artifact::new(kind, None))); + self.item(s, it, parent, page_name); + s.end_tagged(); + return; + } + if let Some(sid) = story { + let id = s.start_tagged(ContentTag::Span(SpanTag::empty())); + self.item(s, it, parent, page_name); + s.end_tagged(); + let v = self.story_tags.entry(sid).or_default(); + if v.is_empty() { + self.tags.push(TagEntry::Story(sid)); + } + v.push(id); + return; + } + let id = s.start_tagged(ContentTag::Other); + self.item(s, it, parent, page_name); + s.end_tagged(); + let alt = (!it.alt_text.is_empty()).then(|| it.alt_text.clone()); + let mut g = TagGroup::new(Tag::Figure(alt)); + g.push(id); + self.tags.push(TagEntry::Figure(g)); + } + + /// The structure tree: stories (one paragraph each, its frames in thread order of appearance) + /// and figures, in the order they first appear. + fn tag_tree(&mut self) -> TagTree { + let mut tree = TagTree::new(); + for e in std::mem::take(&mut self.tags) { + match e { + TagEntry::Story(sid) => { + let mut g = TagGroup::new(Tag::P); + for id in self.story_tags.remove(&sid).unwrap_or_default() { + g.push(id); + } + tree.push(g); + } + TagEntry::Figure(g) => tree.push(g), + } + } + tree + } + fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { let gf = &it.effects.gradient_feather; if !gf.on || it.hidden || it.nonprinting { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 5b9566b92..646b5894f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -107,6 +107,9 @@ pub struct PdfOptions { pub author: Option, /// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). pub created: Option, + /// Tagged PDF: stories become paragraphs in reading order, graphics figures with their alt + /// text, parent-page items and printer's marks artifacts. + pub tagged: bool, } impl Default for PdfOptions { @@ -122,6 +125,7 @@ impl Default for PdfOptions { title: None, author: None, created: None, + tagged: false, } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5bb16fb28..3f2942627 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -941,6 +941,7 @@ fn export_pdf(app: &mut DesignApp, p: &Value) -> Result { if let Some(o) = params.as_object_mut() { o.remove("path"); o.entry("bleed").or_insert(json!(true)); + o.entry("tagged").or_insert(json!(true)); } let r = app.run("file.exportPdf", params)?; let bytes = designcraft_engine::cmd::base64_decode(r["base64"].as_str().unwrap_or_default()); diff --git a/docs/parity.md b/docs/parity.md index 3a821cce4..5afcbf319 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 67 | 26 | 4 | 82% | -| P2 | 76 | 10 | 10 | 56 | 20% | +| P2 | 76 | 11 | 10 | 55 | 21% | | Area | Parity | |---|---| @@ -33,7 +33,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | XML & automation | 50% | | View & navigation | 82% | | Undo, history, saving | 100% | -| Accessibility | 30% | +| Accessibility | 50% | Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print). @@ -312,7 +312,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Undo, history, saving | Unlimited undo/redo | P0 | D | | Undo, history, saving | Revert | P0 | D | | Undo, history, saving | Save / Save As / Save a Copy | P0 | D | -| Accessibility | Tagged PDF export | P2 | M | -| Accessibility | Alt text | P2 | P | +| Accessibility | Tagged PDF export | P2 | P | +| Accessibility | Alt text | P2 | D | | Accessibility | Keyboard navigation of UI | P1 | P | | Accessibility | High contrast | P2 | M |