X: Tagged Text export and place

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 09:40:23 -04:00
parent 55e0efbc62
commit e5bb4551a1
5 changed files with 270 additions and 10 deletions

View File

@ -111,6 +111,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups).
- Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step).
- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML.
- Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters.
- 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.

View File

@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes} (no path: {text, bytes})",
has_doc, export_html),
cmd!(noundo "file.exportText", "Export Text…", ["File"], None,
"{path?, format?: \"txt\"|\"rtf\" (default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})",
"{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})",
has_story_target, export_text),
]
}
@ -71,13 +71,18 @@ fn export_text(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let story = doc.stories.get(&sid).ok_or_else(|| bad(ID, format!("no story {}", sid.0)))?;
let path = str_param(p, "path");
let rtf = match str_param(p, "format") {
Some("rtf") => true,
Some("txt") | Some("text") => false,
Some(f) => return Err(bad(ID, format!("unknown format `{f}` (txt, rtf)"))),
None => path.is_some_and(|x| x.to_ascii_lowercase().ends_with(".rtf")),
let format = match str_param(p, "format") {
Some(f @ ("rtf" | "tagged")) => f,
Some("txt") | Some("text") => "txt",
Some(f) => return Err(bad(ID, format!("unknown format `{f}` (txt, rtf, tagged)"))),
None if path.is_some_and(|x| x.to_ascii_lowercase().ends_with(".rtf")) => "rtf",
None => "txt",
};
let text = match format {
"rtf" => designcraft_textimport::export::rtf(doc, story),
"tagged" => designcraft_textimport::tagged::tagged_text(doc, story),
_ => designcraft_textimport::export::plain_text(story),
};
let text = if rtf { designcraft_textimport::export::rtf(doc, story) } else { designcraft_textimport::export::plain_text(story) };
match path {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
@ -528,6 +533,8 @@ mod text_tests {
assert!(s.execute("file.exportText", &json!({"frame": r["id"]})).unwrap()["text"] == "One\r\nTwo");
let html = s.execute("file.exportHtml", &json!({})).unwrap();
assert!(html["text"].as_str().unwrap().contains("One</p>"));
let tagged = s.execute("file.exportText", &json!({"frame": r["id"], "format": "tagged"})).unwrap();
assert!(tagged["text"].as_str().unwrap().contains("<ParaStyle:"));
let rtf = s.execute("file.exportText", &json!({"frame": r["id"], "format": "rtf"})).unwrap();
assert!(rtf["text"].as_str().unwrap().starts_with("{\\rtf1"));
let dir = std::env::temp_dir().join(format!("dc-export-text-{}", std::process::id()));

View File

@ -10,6 +10,7 @@
mod docx;
pub mod export;
mod rtf;
pub mod tagged;
mod xlsx;
use designcraft_doc::{CharAttrs, ParaAttrs, Story};
@ -66,6 +67,9 @@ pub fn import(name: &str, bytes: &[u8]) -> Result<Imported, ImportError> {
if l.ends_with(".rtf") || bytes.starts_with(b"{\\rtf") {
return rtf::import(bytes);
}
if tagged::is_tagged(bytes) {
return tagged::import(bytes);
}
if l.ends_with(".doc") {
return Err(ImportError::Unsupported("legacy Word .doc (save it as .docx)".into()));
}

View File

@ -0,0 +1,248 @@
//! Tagged Text: plain text with `<Tag:value>` markup for paragraph and character styles and local
//! formatting — the text interchange format of layout applications. Written as ASCII (other
//! characters as `<0xXXXX>`); read from ASCII, UTF-8 or UTF-16 files. Paragraph styles, character
//! styles, font family, font style and size are kept; other tags are skipped.
use std::fmt::Write as _;
use designcraft_doc::{CharFormat, Document, Story, StoryId, story::NO_CHAR_STYLE};
use crate::{ImportError, Imported, ImportedStyle};
fn escape(out: &mut String, c: char) {
match c {
'<' => out.push_str("\\<"),
'>' => out.push_str("\\>"),
'\\' => out.push_str("\\\\"),
c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
'\t' => out.push('\t'),
c => {
let mut buf = [0u16; 2];
for u in c.encode_utf16(&mut buf) {
let _ = write!(out, "<0x{u:04X}>");
}
}
}
}
/// `story` as Tagged Text.
pub fn tagged_text(doc: &Document, story: &Story) -> String {
let mut out = String::from("<ASCII-WIN>\r\n<Version:7>\r\n");
let ranges = story.para_ranges();
for (i, r) in ranges.iter().enumerate() {
if i > 0 {
out.push_str("\r\n");
}
let pf = story.paras.get(i).cloned().unwrap_or_default();
let _ = write!(out, "<ParaStyle:{}>", pf.style.replace(['<', '>', ':'], ""));
for (rr, f) in story.runs() {
let (a, b) = (rr.start.max(r.start), rr.end.min(r.end));
if a >= b {
continue;
}
if doc.conditions_hide(f.over.conditions.as_deref().unwrap_or(&[])) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) {
continue;
}
let mut open = Vec::new();
if f.style != NO_CHAR_STYLE {
let _ = write!(out, "<CharStyle:{}>", f.style.replace(['<', '>', ':'], ""));
open.push("CharStyle");
}
if let Some(v) = &f.over.font_family {
let _ = write!(out, "<cFont:{v}>");
open.push("cFont");
}
if let Some(v) = &f.over.font_style {
let _ = write!(out, "<cTypeface:{v}>");
open.push("cTypeface");
}
if let Some(v) = f.over.size {
let _ = write!(out, "<cSize:{v:.6}>");
open.push("cSize");
}
for c in story.text[a..b].chars() {
escape(&mut out, c);
}
for t in open.into_iter().rev() {
let _ = write!(out, "<{t}:>");
}
}
}
out
}
/// Text of a Tagged Text file (ASCII, UTF-8 or UTF-16 by byte-order mark).
fn decode(bytes: &[u8]) -> String {
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
let le = bytes[0] == 0xFF;
let units: Vec<u16> =
bytes[2..].chunks_exact(2).map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect();
return String::from_utf16_lossy(&units);
}
String::from_utf8_lossy(bytes).trim_start_matches('\u{FEFF}').to_string()
}
/// Is this Tagged Text (by its header)?
pub fn is_tagged(bytes: &[u8]) -> bool {
let head = decode(&bytes[..bytes.len().min(64)]);
let h = head.trim_start();
h.starts_with("<ASCII-") || h.starts_with("<UNICODE-") || h.starts_with("<ANSI-")
}
/// Read Tagged Text.
pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
let text = decode(bytes);
let chars: Vec<char> = text.chars().collect();
// Paragraphs: (style, runs of (text, format)).
let mut paras: Vec<(String, Vec<(String, CharFormat)>)> = vec![(String::new(), Vec::new())];
let mut fmt = CharFormat::default();
let mut para_styles: Vec<String> = Vec::new();
let mut char_styles: Vec<String> = Vec::new();
let mut warnings = Vec::new();
let push = |paras: &mut Vec<(String, Vec<(String, CharFormat)>)>, c: char, fmt: &CharFormat| {
let runs = &mut paras.last_mut().expect("one paragraph").1;
match runs.last_mut() {
Some((t, f)) if f == fmt => t.push(c),
_ => runs.push((c.to_string(), fmt.clone())),
}
};
let mut i = 0;
let mut skipped = 0;
while i < chars.len() {
let c = chars[i];
match c {
'\\' if i + 1 < chars.len() => {
push(&mut paras, chars[i + 1], &fmt);
i += 2;
}
'<' => {
// A tag: up to the matching '>' (nested in definitions).
let mut depth = 0;
let mut j = i;
while j < chars.len() {
match chars[j] {
'\\' => j += 1,
'<' => depth += 1,
'>' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
let tag: String = chars[i + 1..j.min(chars.len())].iter().collect();
i = j + 1;
let (name, value) = tag.split_once(':').map_or((tag.as_str(), ""), |(a, b)| (a, b));
match name {
n if n.starts_with("0x") => {
if let Ok(u) = u32::from_str_radix(&n[2..], 16)
&& let Some(ch) = char::from_u32(u)
{
push(&mut paras, ch, &fmt);
}
}
"ParaStyle" => {
let v = value.trim().to_string();
if !v.is_empty() && !para_styles.contains(&v) {
para_styles.push(v.clone());
}
paras.last_mut().expect("one paragraph").0 = v;
}
"CharStyle" => {
let v = value.trim();
fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.to_string() };
if !v.is_empty() && !char_styles.contains(&v.to_string()) {
char_styles.push(v.to_string());
}
}
"cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()),
"cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| value.to_string()),
"cSize" => fmt.over.size = value.trim().parse().ok(),
"DefineParaStyle" | "DefineCharStyle" => {
let nm = value.split('=').next().unwrap_or("").trim().to_string();
let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles };
if !nm.is_empty() && !list.contains(&nm) {
list.push(nm);
}
}
n if n.starts_with("ASCII-") || n.starts_with("UNICODE-") || n.starts_with("ANSI-") || n == "Version" => {}
_ => skipped += 1,
}
}
'\r' | '\n' => {
// A paragraph break (CRLF counts once).
if c == '\r' && chars.get(i + 1) == Some(&'\n') {
i += 1;
}
let style = paras.last().map(|p| p.0.clone()).unwrap_or_default();
// Header lines before any text don't start paragraphs.
if paras.len() == 1 && paras[0].1.is_empty() {
i += 1;
continue;
}
paras.push((style, Vec::new()));
i += 1;
}
c => {
push(&mut paras, c, &fmt);
i += 1;
}
}
}
if skipped > 0 {
warnings.push(format!("{skipped} formatting tag(s) not kept"));
}
// Build the story.
let mut st = Story::new(StoryId(0));
for (k, (style, runs)) in paras.iter().enumerate() {
if k > 0 {
let n = st.len();
st.insert(n, "\n");
}
for (t, f) in runs {
let n = st.len();
st.insert_with(n, t, f.clone());
}
if !style.is_empty()
&& let Some(p) = st.paras.last_mut()
{
p.style = style.clone();
}
}
let mk = |n: &String| ImportedStyle { name: n.clone(), ..Default::default() };
Ok(Imported { story: st, para_styles: para_styles.iter().map(mk).collect(), char_styles: char_styles.iter().map(mk).collect(), warnings })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tagged_text_round_trips() {
let src = "<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Head>Caf<0x00E9> \\<1\\>\r\n<ParaStyle:Body>A <CharStyle:Em>word<CharStyle:> and <cSize:18.000000>big<cSize:> text";
assert!(is_tagged(src.as_bytes()));
let i = import(src.as_bytes()).unwrap();
assert_eq!(i.story.text, "Café <1>\nA word and big text");
assert_eq!(i.story.paras[0].style, "Head");
assert_eq!(i.story.paras[1].style, "Body");
assert_eq!(i.story.char_format_at(i.story.text.find("word").unwrap() + 1).style, "Em");
assert_eq!(i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size, Some(18.0));
assert_eq!(i.para_styles.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Head", "Body"]);
// Export and back.
let doc = Document::new(&designcraft_doc::build::NewDocument::default());
let out = tagged_text(&doc, &i.story);
assert!(out.starts_with("<ASCII-WIN>") && out.contains("Caf<0x00E9>") && out.contains("<CharStyle:Em>word<CharStyle:>"), "{out}");
let back = import(out.as_bytes()).unwrap();
assert_eq!(back.story.text, i.story.text);
assert_eq!(back.story.paras[1].style, "Body");
// UTF-16 with a byte-order mark.
let mut u16 = vec![0xFF, 0xFE];
for u in "<UNICODE-WIN>\r\n<ParaStyle:X>Hi".encode_utf16() {
u16.extend(u.to_le_bytes());
}
assert_eq!(import(&u16).unwrap().story.text, "Hi");
}
}

View File

@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|---|---|---|---|---|---|
| P0 | 102 | 100 | 2 | 0 | 99% |
| P1 | 97 | 94 | 3 | 0 | 98% |
| P2 | 76 | 54 | 22 | 0 | 86% |
| P2 | 76 | 55 | 21 | 0 | 86% |
| Area | Parity |
|---|---|
@ -29,7 +29,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Tables | 98% |
| Long documents | 100% |
| Interactivity & digital | 90% |
| Output & production | 82% |
| Output & production | 85% |
| XML & automation | 83% |
| View & navigation | 96% |
| Undo, history, saving | 100% |
@ -285,7 +285,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print)
| Output & production | Export JPEG/PNG | P1 | D |
| Output & production | Export IDML | P0 | D |
| Output & production | Export EPUB/HTML | P2 | P |
| Output & production | Export RTF/TXT/Tagged text | P2 | P |
| Output & production | Export RTF/TXT/Tagged text | P2 | D |
| Output & production | Export XML | P2 | P |
| Output & production | Separations / Flattener / Ink limit preview | P2 | D |
| XML & automation | Tags panel, Structure | P2 | P |