A: tagged PDF — structure tree, figures with alt text, artifacts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 21:34:37 -04:00
co-authored by Claude Opus 5.5
parent 286823eb96
commit 9dd6bd4ff6
6 changed files with 112 additions and 7 deletions
+1
View File
@@ -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.
+26 -1
View File
@@ -9,7 +9,7 @@ use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
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<PdfOptions> {
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"));
}
}
+76 -2
View File
@@ -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<u32, Arc<HashMap<u32, char>>>,
/// 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<TagEntry>,
story_tags: HashMap<designcraft_doc::StoryId, Vec<Identifier>>,
}
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 {
+4
View File
@@ -107,6 +107,9 @@ pub struct PdfOptions {
pub author: Option<String>,
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm).
pub created: Option<i64>,
/// 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,
}
}
}
+1
View File
@@ -941,6 +941,7 @@ fn export_pdf(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
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());
+4 -4
View File
@@ -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 |