H: place text files — Word (.docx), RTF, plain text; autoflow

- designcraft-textimport (L3): DOCX (styles by name with spacing/indents/alignment and font,
  size, bold/italic/underline/strike/caps/position; runs, tabs, breaks, footnotes, tables,
  hyperlink/field results; used styles only), RTF (text, paragraphs, bold/italic/underline/
  strike, size, Unicode/cp1252, skipped destinations), plain text (+ tests)
- engine: file.place routes text files into the insertion point, the selected frame or a new
  frame on the page; imported styles added (existing win); removeStyles; autoflow adds pages
  with threaded frames until the story fits (+ test)
- app/web: Place dialogs accept .docx/.rtf/.txt/.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 08:55:28 -04:00
parent f8ccff2470
commit 446c70af04
15 changed files with 1055 additions and 13 deletions

10
Cargo.lock generated
View File

@ -922,6 +922,7 @@ dependencies = [
"designcraft-idml",
"designcraft-pdf",
"designcraft-render",
"designcraft-textimport",
"designcraft-tools",
"hayro-cmap",
"hayro-interpret",
@ -1034,6 +1035,15 @@ dependencies = [
"vello_cpu 0.2.0",
]
[[package]]
name = "designcraft-textimport"
version = "0.1.0"
dependencies = [
"designcraft-doc",
"quick-xml 0.38.4",
"zip",
]
[[package]]
name = "designcraft-tools"
version = "0.1.0"

View File

@ -22,6 +22,7 @@ designcraft-render = { path = "crates/render" }
designcraft-pdf = { path = "crates/pdf" }
designcraft-idml = { path = "crates/idml" }
designcraft-epub = { path = "crates/epub" }
designcraft-textimport = { path = "crates/textimport" }
designcraft-format = { path = "crates/format" }
designcraft-tools = { path = "crates/tools" }
designcraft-engine = { path = "crates/engine" }

View File

@ -29,6 +29,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.

View File

@ -5,7 +5,7 @@ use designcraft_ui_egui::{DesignApp, Inbox, Services};
use wasm_bindgen::JsCast as _;
const DOC_EXTS: &[&str] = &["designcraft", "idml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "txt", "docx", "rtf", "md"];
const CANVAS_ID: &str = "designcraft_canvas";
const LOADING_ID: &str = "designcraft_loading";

View File

@ -82,7 +82,9 @@ fn services() -> Services {
pick_open: Some(Box::new(|purpose: &str| {
let d = rfd::FileDialog::new();
let d = if purpose == "place" {
d.add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"])
d.add_filter("Graphics and text", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "txt", "docx", "rtf", "md"])
.add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"])
.add_filter("Text (Word, RTF, plain)", &["docx", "rtf", "txt", "md"])
} else {
d.add_filter("DesignCraft or IDML", &["designcraft", "idml"])
.add_filter("DesignCraft", &["designcraft"])

View File

@ -17,6 +17,7 @@ designcraft-compose = { workspace = true }
designcraft-render = { workspace = true }
designcraft-format = { workspace = true }
designcraft-epub = { workspace = true }
designcraft-textimport = { workspace = true }
designcraft-pdf = { workspace = true }
designcraft-tools = { workspace = true }
designcraft-idml = { workspace = true }

View File

@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+D"),
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?} — places an image; into the selected empty frame if any",
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?}",
has_doc,
file_place
),
@ -199,6 +199,10 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) });
return ok();
};
// Text files (plain, Word, RTF) flow into frames.
if designcraft_textimport::is_text_file(&name) {
return super::place_text::place_text(s, p, &name, &bytes);
}
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?;
// 72 ppi by default unless the file says otherwise; scale so it fits the page when huge.
let (nw, nh) = (pw as f64, ph as f64);

View File

@ -16,6 +16,7 @@ mod layout;
mod links;
mod notes;
mod object;
mod place_text;
pub mod preflight;
pub mod spelling;
mod style;

View File

@ -0,0 +1,182 @@
//! File › Place of text files (plain text, Word, RTF): into the text insertion point, the
//! selected frame, or a new frame on the page; with autoflow, pages and threaded frames are added
//! until the whole story fits (InDesign's Shift-click with a loaded text cursor).
use designcraft_doc::{CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextSel};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::bad;
use crate::{Result, Session};
/// Add the imported styles the document doesn't have yet (existing styles win, as in InDesign's
/// default style-conflict option).
fn add_styles(d: &mut Document, imp: &designcraft_textimport::Imported) {
let st = d.styles_mut();
for s in &imp.para_styles {
if st.para(&s.name).is_none() {
st.paragraph.push(ParagraphStyle {
name: s.name.clone(),
based_on: s.based_on.clone().or_else(|| Some(designcraft_doc::story::BASIC_PARAGRAPH.into())),
next_style: None,
para: s.para.clone(),
chars: s.chars.clone(),
shortcut: String::new(),
});
}
}
for s in &imp.char_styles {
if !st.character.iter().any(|c| c.name == s.name) {
st.character.push(CharacterStyle { name: s.name.clone(), based_on: s.based_on.clone(), chars: s.chars.clone(), shortcut: String::new() });
}
}
}
/// "Remove styles and formatting": every paragraph [Basic Paragraph], no local formatting.
fn strip(story: &mut Story) {
let n = story.len();
story.format_paras(0..n, |p| *p = ParaFormat { table: p.table, ..Default::default() });
story.format_chars(0..n, |f| *f = Default::default());
}
/// Is the story overset in its frames, and how much of it do its frames show?
fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize) {
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
let shown = cs.frames.iter().map(|f| f.range.end).max().unwrap_or(0);
(cs.overset_at, shown)
}
/// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added.
fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result<usize> {
let mut added = 0;
loop {
let (overset, shown) = fit(d, sid);
let Some(at) = overset else { return Ok(added) };
if added >= max_pages {
return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages")));
}
let len = d.story(sid).map_or(0, |s| s.len());
let frames = d.story(sid).map_or(1, |s| s.frames.len()).max(1);
// Frames still needed at the rate the existing ones are filled (at least one).
let per_frame = (shown.max(at) / frames).max(1);
let want = (len - at).div_ceil(per_frame).clamp(1, max_pages - added);
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?;
let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1));
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
d.insert_pages(Some(page), want, parent)?;
let layer = d.default_layer();
let mut prev = last;
for _ in 0..want {
page += 1;
let (si, pi) = d.page_loc(page).ok_or_else(|| bad("file.place", "page insert failed"))?;
let r = d.spreads[si].pages[pi].margin_rect();
let (fid, _) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?;
d.thread(prev, fid)?;
prev = fid;
}
added += want;
}
}
pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result<Value> {
let mut imp = designcraft_textimport::import(name, bytes).map_err(|e| bad("file.place", e.to_string()))?;
let remove = p.get("removeStyles").and_then(Value::as_bool).unwrap_or(false);
if remove {
strip(&mut imp.story);
imp.para_styles.clear();
imp.char_styles.clear();
}
let autoflow_on = p.get("autoflow").and_then(Value::as_bool).unwrap_or(false);
let st = s.doc()?;
let caret = st.selection.text.filter(|t| t.cell.is_none());
let frame = super::id_param(p, "frame").or_else(|| {
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Text(_))))
});
let page = p.get("page").and_then(Value::as_u64).map(|v| (v as usize).saturating_sub(1));
let rect = super::rect_param(p, "rect");
let warnings = imp.warnings.clone();
s.edit(|d, sel| {
add_styles(d, &imp);
let sid = if let Some(t) = caret {
// Into the text at the insertion point (replacing the selection).
let story = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
story.delete(r.clone());
let end = story.insert_story(r.start, &imp.story);
sel.text = Some(TextSel { anchor: end, focus: end, ..t });
t.story
} else {
let fid = match frame {
Some(f) => f,
None => {
let pg = page.unwrap_or(0).min(d.page_count().saturating_sub(1));
let (si, pi) = d.page_loc(pg).ok_or_else(|| bad("file.place", "no such page"))?;
let r: Rect = rect.unwrap_or_else(|| d.spreads[si].pages[pi].margin_rect());
let layer = d.default_layer();
d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?.0
}
};
// Empty frames become text frames.
let sid = match d.item(fid).map(|i| i.content.clone()) {
Some(Content::Text(t)) => t.story,
Some(_) => {
let sid = StoryId(d.alloc());
let mut ns = Story::new(sid);
ns.frames = vec![fid];
d.stories.insert(sid, std::sync::Arc::new(ns));
if let Some(it) = d.item_mut(fid) {
it.content = Content::Text(designcraft_doc::TextFrame { story: sid, options: Default::default() });
}
sid
}
None => return Err(bad("file.place", "no such frame")),
};
let story = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let n = story.len();
story.delete(0..n);
story.insert_story(0, &imp.story);
if let Some(first) = imp.story.paras.first() {
story.paras[0] = ParaFormat { table: story.paras[0].table, ..first.clone() };
}
*sel = designcraft_doc::Selection::items(vec![fid]);
sid
};
let pages = if autoflow_on { autoflow(d, sid, 2000)? } else { 0 };
let overset = fit(d, sid).0.is_some();
Ok(json!({"story": sid.0, "pagesAdded": pages, "overset": overset, "warnings": warnings}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn place_text_file_with_autoflow() {
let dir = std::env::temp_dir().join(format!("dc-place-text-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
let para = "The quick brown fox jumps over the lazy dog, again and again, to fill the page. ".repeat(12);
std::fs::write(&path, vec![para.as_str(); 60].join("\n")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap();
assert!(r["pagesAdded"].as_u64().unwrap() >= 2, "{r}");
assert_eq!(r["overset"], false);
let d = &s.doc().unwrap().doc;
let sid = StoryId(r["story"].as_u64().unwrap());
assert_eq!(d.story(sid).unwrap().frames.len(), d.page_count());
assert_eq!(d.story(sid).unwrap().paras.len(), 60);
// Into the insertion point of another frame.
let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Start end"})).unwrap();
let sid2 = fr["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid2, "anchor": 6, "focus": 6})).unwrap();
let small = dir.join("small.rtf");
std::fs::write(&small, br"{\rtf1 {\b middle}}").unwrap();
s.execute("file.place", &json!({"path": small.to_string_lossy()})).unwrap();
let st = s.doc().unwrap().doc.story(StoryId(sid2)).unwrap().clone();
assert_eq!(st.text, "Start middle end");
assert_eq!(st.format_after(6).over.font_style.as_deref(), Some("Bold"));
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -0,0 +1,14 @@
[package]
name = "designcraft-textimport"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DesignCraft text import: plain text, Word (.docx) and RTF into stories"
[lints]
workspace = true
[dependencies]
designcraft-doc = { workspace = true }
quick-xml = { workspace = true }
zip = { workspace = true }

View File

@ -0,0 +1,446 @@
//! Word (.docx, Office Open XML) → story.
use std::collections::HashMap;
use std::io::Read;
use designcraft_doc::story::{BASIC_PARAGRAPH, NO_CHAR_STYLE};
use designcraft_doc::{CharAttrs, CharFormat, ParaAttrs, ParaFormat, Story, StoryId, Table};
use quick_xml::events::Event;
use crate::{ImportError, Imported, ImportedStyle};
/// A minimal element tree (local names, attributes by local name).
#[derive(Debug, Default, Clone)]
struct El {
name: String,
attrs: Vec<(String, String)>,
kids: Vec<Node>,
}
#[derive(Debug, Clone)]
enum Node {
El(El),
Text(String),
}
impl El {
fn attr(&self, k: &str) -> Option<&str> {
self.attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str())
}
fn els(&self) -> impl Iterator<Item = &El> {
self.kids.iter().filter_map(|n| if let Node::El(e) = n { Some(e) } else { None })
}
fn child(&self, name: &str) -> Option<&El> {
self.els().find(|e| e.name == name)
}
fn text(&self) -> String {
self.kids
.iter()
.map(|n| match n {
Node::Text(t) => t.clone(),
Node::El(e) => e.text(),
})
.collect()
}
/// `<w:x w:val="…"/>` → the value; a bare `<w:b/>` counts as "on".
fn val(&self, name: &str) -> Option<String> {
self.child(name).map(|e| e.attr("val").unwrap_or("true").to_string())
}
fn on(&self, name: &str) -> Option<bool> {
self.val(name).map(|v| !matches!(v.as_str(), "0" | "false" | "none"))
}
}
fn local(n: &[u8]) -> String {
let s = String::from_utf8_lossy(n);
s.rsplit(':').next().unwrap_or(&s).to_string()
}
fn parse(xml: &str) -> Result<El, ImportError> {
let mut r = quick_xml::Reader::from_str(xml);
let mut stack: Vec<El> = vec![El::default()];
loop {
match r.read_event().map_err(|e| ImportError::Corrupt(e.to_string()))? {
Event::Start(e) => {
let el = El {
name: local(e.name().as_ref()),
attrs: e
.attributes()
.flatten()
.map(|a| (local(a.key.as_ref()), a.unescape_value().map(|v| v.to_string()).unwrap_or_default()))
.collect(),
kids: vec![],
};
stack.push(el);
}
Event::Empty(e) => {
let el = El {
name: local(e.name().as_ref()),
attrs: e
.attributes()
.flatten()
.map(|a| (local(a.key.as_ref()), a.unescape_value().map(|v| v.to_string()).unwrap_or_default()))
.collect(),
kids: vec![],
};
if let Some(p) = stack.last_mut() {
p.kids.push(Node::El(el));
}
}
Event::End(_) => {
let el = stack.pop().unwrap_or_default();
match stack.last_mut() {
Some(p) => p.kids.push(Node::El(el)),
None => return Ok(el),
}
}
Event::Text(t) => {
let s = t.decode().map(|s| s.to_string()).unwrap_or_default();
let s = quick_xml::escape::unescape(&s).map(|s| s.to_string()).unwrap_or(s);
if let Some(p) = stack.last_mut() {
p.kids.push(Node::Text(s));
}
}
Event::GeneralRef(e) => {
let name = String::from_utf8_lossy(&e).to_string();
let s = match name.as_str() {
"amp" => "&".to_string(),
"lt" => "<".into(),
"gt" => ">".into(),
"quot" => "\"".into(),
"apos" => "'".into(),
n if n.starts_with("#x") => u32::from_str_radix(&n[2..], 16).ok().and_then(char::from_u32).map(String::from).unwrap_or_default(),
n if n.starts_with('#') => n[1..].parse::<u32>().ok().and_then(char::from_u32).map(String::from).unwrap_or_default(),
_ => String::new(),
};
if let Some(p) = stack.last_mut() {
p.kids.push(Node::Text(s));
}
}
Event::Eof => break,
_ => {}
}
}
let mut root = stack.pop().unwrap_or_default();
Ok(root.kids.drain(..).find_map(|n| if let Node::El(e) = n { Some(e) } else { None }).unwrap_or_default())
}
fn part(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str) -> Option<String> {
let mut f = zip.by_name(name).ok()?;
let mut s = String::new();
f.read_to_string(&mut s).ok()?;
Some(s)
}
/// Character attributes from `<w:rPr>`.
fn run_attrs(rpr: Option<&El>) -> CharAttrs {
let mut a = CharAttrs::default();
let Some(r) = rpr else { return a };
let bold = r.on("b");
let italic = r.on("i");
a.font_style = match (bold, italic) {
(Some(true), Some(true)) => Some("Bold Italic".into()),
(Some(true), _) => Some("Bold".into()),
(_, Some(true)) => Some("Italic".into()),
(Some(false), Some(false)) | (Some(false), None) | (None, Some(false)) => Some("Regular".into()),
_ => None,
};
if let Some(u) = r.val("u") {
a.underline = Some(u != "none" && u != "0" && u != "false");
}
if let Some(s) = r.on("strike") {
a.strikethrough = Some(s);
}
if let Some(sz) = r.val("sz").and_then(|v| v.parse::<f64>().ok()) {
a.size = Some(sz / 2.0);
}
if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) {
a.font_family = Some(f.to_string());
}
if r.on("caps") == Some(true) {
a.capitalization = Some(designcraft_doc::Capitalization::AllCaps);
}
if let Some(v) = r.val("vertAlign") {
a.position = Some(match v.as_str() {
"superscript" => designcraft_doc::Position::Superscript,
"subscript" => designcraft_doc::Position::Subscript,
_ => designcraft_doc::Position::Normal,
});
}
a
}
/// Paragraph attributes from `<w:pPr>` (spacing in twips = 1/20 pt, indents likewise).
fn para_attrs(ppr: Option<&El>) -> ParaAttrs {
let mut a = ParaAttrs::default();
let Some(p) = ppr else { return a };
let tw = |v: Option<&str>| v.and_then(|v| v.parse::<f64>().ok()).map(|v| v / 20.0);
if let Some(sp) = p.child("spacing") {
a.space_before = tw(sp.attr("before"));
a.space_after = tw(sp.attr("after"));
}
if let Some(ind) = p.child("ind") {
a.left_indent = tw(ind.attr("left").or(ind.attr("start")));
a.right_indent = tw(ind.attr("right").or(ind.attr("end")));
a.first_line_indent = tw(ind.attr("firstLine")).or(tw(ind.attr("hanging")).map(|h| -h));
}
if let Some(j) = p.val("jc") {
a.align = Some(match j.as_str() {
"center" => designcraft_doc::Align::Center,
"right" | "end" => designcraft_doc::Align::Right,
"both" | "distribute" => designcraft_doc::Align::LeftJustified,
_ => designcraft_doc::Align::Left,
});
}
a
}
struct Ctx {
para_names: HashMap<String, String>,
char_names: HashMap<String, String>,
footnotes: HashMap<String, El>,
warnings: Vec<String>,
next_table: u64,
}
impl Ctx {
fn para_style(&self, ppr: Option<&El>) -> String {
ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.para_names.get(&id).cloned()).unwrap_or_else(|| BASIC_PARAGRAPH.into())
}
/// Append the paragraphs and tables of `body` to `st`.
fn blocks(&mut self, body: &El, st: &mut Story, allow_notes: bool) {
let mut first = st.is_empty();
for e in body.els() {
match e.name.as_str() {
"p" => {
if !first {
let len = st.len();
st.insert(len, "\n");
}
first = false;
let ppr = e.child("pPr");
let style = self.para_style(ppr);
let attrs = para_attrs(ppr);
let at = st.len();
st.format_paras(at..at, |f| *f = ParaFormat { style: style.clone(), para: attrs.clone(), ..Default::default() });
self.inline(e, st, allow_notes);
}
"tbl" => {
let rows: Vec<Vec<String>> = e
.els()
.filter(|r| r.name == "tr")
.map(|r| {
r.els()
.filter(|c| c.name == "tc")
.map(|c| c.els().filter(|p| p.name == "p").map(|p| p.text()).collect::<Vec<_>>().join("\n"))
.collect()
})
.collect();
if rows.is_empty() {
continue;
}
self.next_table += 1;
let t = Table::from_strings(self.next_table, &rows, 300.0, &ParaFormat::default(), &CharFormat::default());
let len = st.len();
st.insert_table(len, t);
first = false;
}
"sdt" => {
if let Some(c) = e.child("sdtContent") {
self.blocks(c, st, allow_notes);
}
}
_ => {}
}
}
}
/// Runs (and hyperlinks, fields' results) of a paragraph.
fn inline(&mut self, p: &El, st: &mut Story, allow_notes: bool) {
for e in p.els() {
match e.name.as_str() {
"r" => self.run(e, st, allow_notes),
"hyperlink" | "smartTag" | "ins" | "fldSimple" | "customXml" => self.inline(e, st, allow_notes),
_ => {}
}
}
}
fn run(&mut self, r: &El, st: &mut Story, allow_notes: bool) {
let rpr = r.child("rPr");
let style = rpr.and_then(|p| p.val("rStyle")).and_then(|id| self.char_names.get(&id).cloned()).unwrap_or_else(|| NO_CHAR_STYLE.into());
let fmt = CharFormat { style, over: run_attrs(rpr) };
for e in r.els() {
let text = match e.name.as_str() {
"t" => e.text(),
"tab" => "\t".into(),
"br" => match e.attr("type") {
Some("page") => designcraft_doc::PAGE_BREAK.to_string(),
Some("column") => designcraft_doc::COLUMN_BREAK.to_string(),
_ => designcraft_doc::FORCED_LINE_BREAK.to_string(),
},
"noBreakHyphen" => "\u{2011}".into(),
"softHyphen" => "\u{AD}".into(),
"footnoteReference" if allow_notes => {
if let Some(note) = e.attr("id").and_then(|id| self.footnotes.get(id).cloned()) {
let mut ns = Story::new(StoryId(0));
self.blocks(&note, &mut ns, false);
// Word puts the reference mark in the note text: we generate the number.
let len = st.len();
let id = st.insert_note(len, "", ParaFormat::default());
if let Some(n) = st.note_mut(id) {
n.text = ns;
}
}
continue;
}
"drawing" | "pict" | "object" => {
if !self.warnings.iter().any(|w| w.starts_with("Images")) {
self.warnings.push("Images and drawings in the Word file were not imported (place them separately).".into());
}
continue;
}
_ => continue,
};
if !text.is_empty() {
let len = st.len();
st.insert_with(len, &text, fmt.clone());
}
}
}
}
pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?;
let doc = part(&mut zip, "word/document.xml").ok_or_else(|| ImportError::Corrupt("no word/document.xml (not a Word document)".into()))?;
let doc = parse(&doc)?;
// Styles.
let mut ctx = Ctx { para_names: HashMap::new(), char_names: HashMap::new(), footnotes: HashMap::new(), warnings: vec![], next_table: 0 };
let mut para_styles = Vec::new();
let mut char_styles = Vec::new();
if let Some(styles) = part(&mut zip, "word/styles.xml").map(|s| parse(&s)).transpose()? {
let ids: HashMap<String, String> =
styles.els().filter(|s| s.name == "style").filter_map(|s| Some((s.attr("styleId")?.to_string(), s.val("name")?))).collect();
for s in styles.els().filter(|s| s.name == "style") {
let (Some(id), Some(name)) = (s.attr("styleId"), s.val("name")) else { continue };
let kind = s.attr("type").unwrap_or("paragraph");
// Word's Normal / default paragraph font are our [Basic Paragraph] / [None].
let name = match (kind, name.as_str()) {
("paragraph", "Normal") => BASIC_PARAGRAPH.to_string(),
("character", "Default Paragraph Font") => NO_CHAR_STYLE.to_string(),
_ => name.clone(),
};
let based_on = s.val("basedOn").and_then(|b| ids.get(&b).cloned()).map(|b| if b == "Normal" { BASIC_PARAGRAPH.to_string() } else { b });
let imp = ImportedStyle { name: name.clone(), based_on, para: para_attrs(s.child("pPr")), chars: run_attrs(s.child("rPr")) };
match kind {
"paragraph" => {
ctx.para_names.insert(id.to_string(), name.clone());
if name != BASIC_PARAGRAPH {
para_styles.push(imp);
}
}
"character" => {
ctx.char_names.insert(id.to_string(), name.clone());
if name != NO_CHAR_STYLE {
char_styles.push(imp);
}
}
_ => {}
}
}
}
// Footnotes (ids -1 / 0 are the separators).
if let Some(f) = part(&mut zip, "word/footnotes.xml").map(|s| parse(&s)).transpose()? {
for n in f.els().filter(|n| n.name == "footnote") {
if let Some(id) = n.attr("id").filter(|id| id.parse::<i64>().is_ok_and(|v| v > 0)) {
ctx.footnotes.insert(id.to_string(), n.clone());
}
}
}
let body = doc.child("body").ok_or_else(|| ImportError::Corrupt("no document body".into()))?;
let mut st = Story::new(StoryId(0));
ctx.blocks(body, &mut st, true);
// Only styles that are used, plus what they are based on.
let used_p: std::collections::HashSet<String> = st.paras.iter().map(|p| p.style.clone()).collect();
let used_c: std::collections::HashSet<String> = st.runs().map(|(_, f)| f.style.clone()).collect();
let keep = |list: Vec<ImportedStyle>, used: &std::collections::HashSet<String>| {
let mut want: std::collections::HashSet<String> = used.clone();
loop {
let more: Vec<String> =
list.iter().filter(|s| want.contains(&s.name)).filter_map(|s| s.based_on.clone()).filter(|b| !want.contains(b)).collect();
if more.is_empty() {
break;
}
want.extend(more);
}
list.into_iter().filter(|s| want.contains(&s.name)).collect::<Vec<_>>()
};
Ok(Imported { story: st, para_styles: keep(para_styles, &used_p), char_styles: keep(char_styles, &used_c), warnings: ctx.warnings })
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
pub(crate) fn docx(document: &str, styles: &str, footnotes: &str) -> Vec<u8> {
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for (n, c) in [("word/document.xml", document), ("word/styles.xml", styles), ("word/footnotes.xml", footnotes)] {
if c.is_empty() {
continue;
}
w.start_file(n, o).unwrap();
w.write_all(c.as_bytes()).unwrap();
}
w.finish().unwrap().into_inner()
}
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
#[test]
fn imports_styles_runs_footnotes_and_tables() {
let styles = format!(
r#"<w:styles {W}>
<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/>
<w:pPr><w:spacing w:before="240" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Unused"><w:name w:val="Unused"/></w:style>
<w:style w:type="character" w:styleId="Emph"><w:name w:val="Emphasis"/><w:rPr><w:i/></w:rPr></w:style>
</w:styles>"#
);
let document = format!(
r#"<w:document {W}><w:body>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Title &amp; more</w:t></w:r></w:p>
<w:p><w:r><w:t xml:space="preserve">Plain </w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>bold</w:t></w:r>
<w:r><w:rPr><w:rStyle w:val="Emph"/></w:rPr><w:t> styled</w:t></w:r><w:r><w:tab/><w:t>x</w:t></w:r>
<w:r><w:footnoteReference w:id="1"/></w:r></w:p>
<w:tbl><w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
<w:p><w:r><w:t>After</w:t></w:r></w:p>
</w:body></w:document>"#
);
let notes = format!(
r#"<w:footnotes {W}><w:footnote w:id="0"><w:p/></w:footnote>
<w:footnote w:id="1"><w:p><w:r><w:t>The note.</w:t></w:r></w:p></w:footnote></w:footnotes>"#
);
let i = import(&docx(&document, &styles, &notes)).unwrap();
let st = &i.story;
st.check().unwrap();
assert!(st.text.starts_with("Title & more\nPlain bold styled\tx"), "{:?}", st.text);
assert!(st.text.ends_with("After"));
assert_eq!(st.paras[0].style, "heading 1");
assert_eq!(st.paras[1].style, BASIC_PARAGRAPH);
let at = st.text.find("bold").unwrap();
assert_eq!(st.format_after(at).over.font_style.as_deref(), Some("Bold"));
assert_eq!(st.format_after(st.text.find("styled").unwrap()).style, "Emphasis");
assert_eq!(st.notes.len(), 1);
assert_eq!(st.notes[0].text.text, "The note.");
assert_eq!(st.tables.len(), 1);
let names: Vec<&str> = i.para_styles.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["heading 1"], "unused styles are skipped");
assert_eq!(i.para_styles[0].chars.size, Some(16.0));
assert_eq!(i.para_styles[0].para.space_before, Some(12.0));
assert_eq!(i.char_styles[0].chars.font_style.as_deref(), Some("Italic"));
}
}

View File

@ -0,0 +1,89 @@
//! Text import (File › Place of text files): plain text, Word documents (`.docx`) and RTF become
//! a frameless [`Story`] with paragraph and character styles, local bold/italic/underline/size,
//! footnotes (Word) and tables (Word), ready to flow into text frames.
//!
//! Word styles are imported by name (their font, size, bold/italic and space before/after become
//! the style definition, like InDesign's "Use Word styles" import option). RTF keeps bold,
//! italic, underline, size and paragraph breaks.
#![forbid(unsafe_code)]
mod docx;
mod rtf;
use designcraft_doc::{CharAttrs, ParaAttrs, Story};
/// A paragraph or character style found in the source.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ImportedStyle {
pub name: String,
pub based_on: Option<String>,
pub para: ParaAttrs,
pub chars: CharAttrs,
}
/// The imported text.
#[derive(Clone, Debug)]
pub struct Imported {
pub story: Story,
pub para_styles: Vec<ImportedStyle>,
pub char_styles: Vec<ImportedStyle>,
/// Anything the import couldn't keep (images, fields, …).
pub warnings: Vec<String>,
}
#[derive(Debug, PartialEq)]
pub enum ImportError {
Unsupported(String),
Corrupt(String),
}
impl std::fmt::Display for ImportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ImportError::Unsupported(s) => write!(f, "unsupported text file: {s}"),
ImportError::Corrupt(s) => write!(f, "can't read the file: {s}"),
}
}
}
/// Text files this crate reads, by extension.
pub fn is_text_file(name: &str) -> bool {
let l = name.to_ascii_lowercase();
l.ends_with(".txt") || l.ends_with(".text") || l.ends_with(".md") || l.ends_with(".docx") || l.ends_with(".rtf")
}
/// Import `bytes` (format from the file name, else sniffed).
pub fn import(name: &str, bytes: &[u8]) -> Result<Imported, ImportError> {
let l = name.to_ascii_lowercase();
if l.ends_with(".docx") || bytes.starts_with(b"PK\x03\x04") {
return docx::import(bytes);
}
if l.ends_with(".rtf") || bytes.starts_with(b"{\\rtf") {
return rtf::import(bytes);
}
if l.ends_with(".doc") {
return Err(ImportError::Unsupported("legacy Word .doc (save it as .docx)".into()));
}
Ok(plain(&String::from_utf8_lossy(bytes)))
}
/// Plain text: line breaks become paragraphs (CRLF / CR normalised).
pub fn plain(text: &str) -> Imported {
let t = text.trim_start_matches('\u{FEFF}').replace("\r\n", "\n").replace('\r', "\n");
let story = Story::with_text(designcraft_doc::StoryId(0), &t, Default::default());
Imported { story, para_styles: vec![], char_styles: vec![], warnings: vec![] }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_text_paragraphs() {
let i = import("a.txt", b"\xEF\xBB\xBFOne\r\nTwo\rThree").unwrap();
assert_eq!(i.story.text, "One\nTwo\nThree");
assert_eq!(i.story.paras.len(), 3);
assert!(is_text_file("Report.DOCX") && !is_text_file("a.png"));
assert!(matches!(import("x.doc", b"\xD0\xCF"), Err(ImportError::Unsupported(_))));
}
}

View File

@ -0,0 +1,290 @@
//! RTF → story: text, paragraphs, line breaks, tabs, bold/italic/underline/strikethrough, size,
//! Unicode (`\uN`) and hex (`\'hh`) characters. Destinations that aren't body text (font and
//! colour tables, stylesheet, info, pictures, `\*` groups) are skipped.
use designcraft_doc::{CharAttrs, CharFormat, Story, StoryId};
use crate::{ImportError, Imported};
#[derive(Clone, Default)]
struct State {
bold: bool,
italic: bool,
underline: bool,
strike: bool,
size: Option<f64>,
/// Inside a destination whose text is ignored.
skip: bool,
/// Characters to skip after `\uN` (`\ucN`).
uc: usize,
}
impl State {
fn format(&self) -> CharFormat {
let font_style = match (self.bold, self.italic) {
(true, true) => Some("Bold Italic".to_string()),
(true, false) => Some("Bold".into()),
(false, true) => Some("Italic".into()),
_ => None,
};
CharFormat {
style: designcraft_doc::story::NO_CHAR_STYLE.into(),
over: CharAttrs {
font_style,
underline: self.underline.then_some(true),
strikethrough: self.strike.then_some(true),
size: self.size,
..Default::default()
},
}
}
}
const SKIP: &[&str] = &[
"fonttbl",
"colortbl",
"stylesheet",
"info",
"pict",
"header",
"footer",
"footnote",
"listtable",
"listoverridetable",
"rsidtbl",
"generator",
"themedata",
"colorschememapping",
"latentstyles",
"datastore",
"xmlnstbl",
"fldinst",
"object",
];
pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
let src = String::from_utf8_lossy(bytes);
if !src.trim_start().starts_with("{\\rtf") {
return Err(ImportError::Corrupt("not an RTF file".into()));
}
let mut st = Story::new(StoryId(0));
let mut stack: Vec<State> = vec![State { uc: 1, ..Default::default() }];
let mut pending = 0usize; // characters still to skip after \uN
let mut buf = String::new();
let mut fmt = stack[0].format();
let mut warnings = Vec::new();
let flush = |st: &mut Story, buf: &mut String, fmt: &CharFormat| {
if !buf.is_empty() {
let len = st.len();
st.insert_with(len, buf, fmt.clone());
buf.clear();
}
};
let chars: Vec<char> = src.chars().collect();
let mut i = 0;
// The first `{` opens the document group.
let mut first = true;
while i < chars.len() {
let c = chars[i];
match c {
'{' => {
flush(&mut st, &mut buf, &fmt);
let top = stack.last().cloned().unwrap_or_default();
if !first {
stack.push(top);
}
first = false;
// `{\*\dest …}`: an ignorable destination.
if chars.get(i + 1) == Some(&'\\')
&& chars.get(i + 2) == Some(&'*')
&& let Some(s) = stack.last_mut()
{
s.skip = true;
}
i += 1;
}
'}' => {
flush(&mut st, &mut buf, &fmt);
if stack.len() > 1 {
stack.pop();
}
fmt = stack.last().map(State::format).unwrap_or_default();
i += 1;
}
'\\' => {
let next = chars.get(i + 1).copied().unwrap_or(' ');
if next == '\'' {
// \'hh: a Windows-1252 byte.
let hex: String = chars.iter().skip(i + 2).take(2).collect();
i += 4;
if pending > 0 {
pending -= 1;
continue;
}
if let Ok(b) = u8::from_str_radix(&hex, 16)
&& !stack.last().is_some_and(|s| s.skip)
{
buf.push(cp1252(b));
}
continue;
}
if !next.is_ascii_alphabetic() {
// Control symbols: \\ \{ \} \~ (nbsp) \- (soft hyphen) \_ (nb hyphen).
let skip = stack.last().is_some_and(|s| s.skip);
if !skip {
match next {
'\\' | '{' | '}' => buf.push(next),
'~' => buf.push('\u{A0}'),
'-' => buf.push('\u{AD}'),
'_' => buf.push('\u{2011}'),
'\n' | '\r' => {
flush(&mut st, &mut buf, &fmt);
let len = st.len();
st.insert(len, "\n");
}
_ => {}
}
}
i += 2;
continue;
}
// A control word with an optional numeric parameter and one delimiting space.
let mut j = i + 1;
while j < chars.len() && chars[j].is_ascii_alphabetic() {
j += 1;
}
let word: String = chars[i + 1..j].iter().collect();
let mut k = j;
if k < chars.len() && (chars[k] == '-' || chars[k].is_ascii_digit()) {
k += 1;
while k < chars.len() && chars[k].is_ascii_digit() {
k += 1;
}
}
let param: Option<i64> = chars[j..k].iter().collect::<String>().parse().ok();
if k < chars.len() && chars[k] == ' ' {
k += 1;
}
i = k;
let skip = stack.last().is_some_and(|s| s.skip);
if SKIP.contains(&word.as_str()) {
flush(&mut st, &mut buf, &fmt);
if let Some(s) = stack.last_mut() {
s.skip = true;
}
if word == "pict" && !warnings.iter().any(|w: &String| w.starts_with("Pictures")) {
warnings.push("Pictures in the RTF file were not imported.".into());
}
continue;
}
if skip {
continue;
}
let on = param != Some(0);
let mut restyle = |f: &dyn Fn(&mut State)| {
flush(&mut st, &mut buf, &fmt);
if let Some(s) = stack.last_mut() {
f(s);
fmt = s.format();
}
};
match word.as_str() {
"par" | "sect" => {
flush(&mut st, &mut buf, &fmt);
let len = st.len();
st.insert_with(len, "\n", fmt.clone());
}
"line" => buf.push(designcraft_doc::FORCED_LINE_BREAK),
"tab" => buf.push('\t'),
"page" => buf.push(designcraft_doc::PAGE_BREAK),
"column" => buf.push(designcraft_doc::COLUMN_BREAK),
"emdash" => buf.push('\u{2014}'),
"endash" => buf.push('\u{2013}'),
"bullet" => buf.push('\u{2022}'),
"lquote" => buf.push('\u{2018}'),
"rquote" => buf.push('\u{2019}'),
"ldblquote" => buf.push('\u{201C}'),
"rdblquote" => buf.push('\u{201D}'),
"b" => restyle(&|s| s.bold = on),
"i" => restyle(&|s| s.italic = on),
"ul" => restyle(&|s| s.underline = on),
"ulnone" => restyle(&|s| s.underline = false),
"strike" => restyle(&|s| s.strike = on),
"fs" => restyle(&|s| s.size = param.map(|p| p as f64 / 2.0)),
"plain" => restyle(&|s| {
s.bold = false;
s.italic = false;
s.underline = false;
s.strike = false;
s.size = None;
}),
"uc" => {
if let (Some(s), Some(n)) = (stack.last_mut(), param) {
s.uc = n.max(0) as usize;
}
}
"u" => {
if let Some(n) = param {
let cp = if n < 0 { (n + 65536) as u32 } else { n as u32 };
if let Some(ch) = char::from_u32(cp) {
buf.push(ch);
}
pending = stack.last().map_or(1, |s| s.uc);
}
}
_ => {}
}
}
'\r' | '\n' => i += 1,
_ => {
i += 1;
if pending > 0 {
pending -= 1;
continue;
}
if !stack.last().is_some_and(|s| s.skip) {
buf.push(c);
}
}
}
}
flush(&mut st, &mut buf, &fmt);
// A trailing \par leaves an empty last paragraph.
if st.text.ends_with('\n') {
let n = st.len();
st.delete(n - 1..n);
}
Ok(Imported { story: st, para_styles: vec![], char_styles: vec![], warnings })
}
/// Windows-1252 byte → char.
fn cp1252(b: u8) -> char {
const HI: [u16; 32] = [
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
];
if (0x80..0xA0).contains(&b) { char::from_u32(HI[(b - 0x80) as usize] as u32).unwrap_or('?') } else { b as char }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn imports_rtf_text_and_formatting() {
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;}
\f0\fs24 Hello {\b bold} and {\i italic\i0 plain}\par
Caf\'e9 \u8364? price\tab tab\line next\par
}";
let i = import(rtf).unwrap();
let st = &i.story;
st.check().unwrap();
assert_eq!(st.text, "Hello bold and italic plain\nCafé € price\ttab\u{2028}next");
let at = st.text.find("bold").unwrap();
assert_eq!(st.format_after(at).over.font_style.as_deref(), Some("Bold"));
assert_eq!(st.format_after(st.text.find("italic").unwrap()).over.font_style.as_deref(), Some("Italic"));
assert_eq!(st.format_after(st.text.find("plain").unwrap()).over.font_style, None);
assert_eq!(st.format_after(0).over.size, Some(12.0));
assert!(!st.text.contains("Times"), "font table skipped");
}
}

View File

@ -5,24 +5,24 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
<!-- SUMMARY -->
**Weighted breadth parity: 63%** over 275 features.
**Weighted breadth parity: 66%** over 275 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 79 | 18 | 5 | 86% |
| P1 | 97 | 31 | 24 | 42 | 44% |
| P0 | 102 | 87 | 12 | 3 | 91% |
| P1 | 97 | 32 | 24 | 41 | 45% |
| P2 | 76 | 8 | 7 | 61 | 15% |
| Area | Parity |
|---|---|
| Application shell & workspace | 57% |
| Application shell & workspace | 65% |
| Documents, pages, spreads | 66% |
| Layers | 74% |
| Frames, shapes & paths | 70% |
| Frames, shapes & paths | 74% |
| Transform | 56% |
| Fill, stroke, colour | 46% |
| Fill, stroke, colour | 62% |
| Effects & transparency | 50% |
| Placing & links | 49% |
| Placing & links | 53% |
| Type & text frames | 77% |
| Typography | 76% |
| Styles | 55% |
@ -32,10 +32,10 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Output & production | 48% |
| XML & automation | 39% |
| View & navigation | 62% |
| Undo, history, saving | 67% |
| Undo, history, saving | 100% |
| Accessibility | 20% |
Open P0 items: Dockable panels, Recovery / autosave, Preferences, Document Setup, Parent item overrides, Polygon settings, Pen, add/delete anchor, convert direction, Paste Into / Paste in Place, Scale strokes / content options, Dimensions Include Stroke Weight, Color panel, Color Picker dialog, Gradient panel & tool, Stroke panel, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Text import (TXT/DOCX/RTF), Display performance, OpenType features, Export to PDF (print), Display performance, Revert, Save / Save As / Save a Copy.
Open P0 items: Dockable panels, Preferences, Document Setup, Parent item overrides, Pen, add/delete anchor, convert direction, Scale strokes / content options, Dimensions Include Stroke Weight, Gradient panel & tool, Stroke panel, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, OpenType features, Export to PDF (print), Display performance.
## Rows
@ -171,7 +171,7 @@ Open P0 items: Dockable panels, Recovery / autosave, Preferences, Document Setup
| Placing & links | Raster formats | P0 | P |
| Placing & links | Vector/page formats (PDF/AI/SVG/EPS place) | P0 | M |
| Placing & links | Placing INDD/IDML pages | P2 | M |
| Placing & links | Text import (TXT/DOCX/RTF) | P0 | P |
| Placing & links | Text import (TXT/DOCX/RTF) | P0 | D |
| Placing & links | Links panel | P0 | D |
| Placing & links | Effective/actual PPI | P0 | D |
| Placing & links | Relink to folder / extension | P1 | M |

View File

@ -43,6 +43,7 @@ pub const TABLE: &[(&str, Class)] = &[
("pdf", Class::Layer(3)),
("idml", Class::Layer(3)),
("epub", Class::Layer(3)),
("textimport", Class::Layer(3)),
("format", Class::Layer(3)),
("tools", Class::Layer(4)),
("engine", Class::Layer(5)),