mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-12 00:41:12 -06:00
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:
co-authored by
Claude Opus 5.5
parent
286823eb96
commit
9dd6bd4ff6
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user