IDML: rules follow the text colour; defaults for auto-size, numbering and table fills

Paragraph rules can be in Text Color: they take the colour of the
paragraph's first character (rule above) or last character (rule below).
That is the default, and IDML maps RuleAboveColor / RuleBelowColor
"Text Color" both ways.

Defaults for what a file leaves unset, in the model and in IDML import:
text frames auto-size from the centre, numbered lists don't continue
across stories, and alternating table and table style fills start
Black 20 % then None 100 %. Kashidas (DefaultKashidas / KashidasOff)
import and export. A tint of -1 is the colour's own tint everywhere tints
are imported (underline and strikethrough, footnote rules included), and
tints are capped at 100 %.

Format 1 documents load as saved: they wrote rules, text frame options,
lists and table fills in full, so nothing relied on the old defaults.
This commit is contained in:
aiongg
2026-10-07 09:05:00 -04:00
parent 4a2a9ff7dd
commit 7a72faf740
11 changed files with 251 additions and 57 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`).
@@ -97,7 +97,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web).
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback.
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default; IDML Kashidas): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
+14 -6
View File
@@ -651,6 +651,16 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
);
}
}
// Rules in Text Color: the colour of the paragraph's first character (rule above) and last
// character (rule below).
let text_color = |g: Option<&Glyph>| {
g.and_then(|g| table.styles.get(g.style as usize))
.map_or((base_chars.fill.clone(), base_chars.fill_tint), |s| (s.fill.clone(), s.fill_tint))
};
let (above, below) = (text_color(sp.glyphs.iter().find(|g| g.len > 0)), text_color(sp.glyphs.iter().rev().find(|g| g.len > 0)));
let rule_color = |r: &designcraft_doc::Rule, text: &(String, f32)| {
if r.color == designcraft_doc::TEXT_COLOR { text.clone() } else { (r.color.clone(), r.tint) }
};
let drop_cap = split_drop_cap(&mut sp.glyphs, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid);
match pp.list_type {
designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => {
@@ -972,11 +982,8 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
let r = &pp.rule_above;
let y = bl - asc - r.offset;
let col = ft.lines.iter().rev().find(|l| l.para == pi).map(|l| (l.x0, l.x1)).unwrap_or((0.0, 0.0));
ft.decos.push(Deco {
rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y),
color: r.color.clone(),
tint: r.tint,
});
let (color, tint) = rule_color(r, &above);
ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color, tint });
}
}
para_box_decos(&mut out, pi, &pp);
@@ -987,7 +994,8 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
let r = &pp.rule_below;
let y = l.baseline + r.offset;
let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) };
ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color: r.color.clone(), tint: r.tint });
let (color, tint) = rule_color(r, &below);
ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint });
}
cur.pending += pp.space_after;
pi += 1;
+26
View File
@@ -1723,3 +1723,29 @@ fn drop_cap_sits_on_its_line_as_set() {
let body = lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap();
assert!((lines[2].baseline - cap_of(dc) - (lines[0].baseline - cap_of(body))).abs() < 0.01);
}
/// A rule in Text Color takes the colour of the paragraph's text: the first character's for the
/// rule above, the last character's for the rule below. A swatch colour is used as is.
#[test]
fn text_color_rules_follow_the_text() {
use designcraft_doc::{CharAttrs, CharFormat, Rule, TEXT_COLOR};
let rule = Rule { on: true, weight: 2.0, ..Default::default() };
let para = ParaAttrs { rule_above: Some(rule.clone()), rule_below: Some(rule.clone()), ..Default::default() };
let (mut d, sid, fid) = doc_with("Red then blue", Rect::new(0.0, 0.0, 300.0, 200.0), para);
{
let s = d.story_mut(sid).unwrap();
s.format_chars(0..3, |f: &mut CharFormat| f.over.merge(&CharAttrs { fill: Some("Red".into()), fill_tint: Some(0.5), ..Default::default() }));
s.format_chars(9..13, |f: &mut CharFormat| f.over.fill = Some("Blue".into()));
}
let decos = |d: &Document| {
let cs = compose_story(d, sid, &ComposeOptions::default());
let mut v: Vec<(f64, String, f32)> = cs.frame(fid).unwrap().decos.iter().map(|dc| (dc.rect.y0, dc.color.clone(), dc.tint)).collect();
v.sort_by(|a, b| a.0.total_cmp(&b.0));
v.into_iter().map(|(_, c, t)| (c, t)).collect::<Vec<_>>()
};
assert_eq!(rule.color, TEXT_COLOR);
assert_eq!(decos(&d), vec![("Red".to_string(), 0.5), ("Blue".to_string(), 1.0)]);
let swatch = Rule { color: "[Black]".into(), tint: 0.3, ..rule };
d.story_mut(sid).unwrap().paras[0].para.rule_above = Some(swatch);
assert_eq!(decos(&d)[0], ("[Black]".to_string(), 0.3));
}
+5 -1
View File
@@ -390,12 +390,16 @@ pub struct TabStop {
pub align_on: String,
}
/// The colour of a paragraph rule that follows the paragraph's text colour.
pub const TEXT_COLOR: &str = "Text Color";
/// A paragraph rule (Rule Above / Rule Below).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Rule {
pub on: bool,
pub weight: f64,
/// A swatch, or [`TEXT_COLOR`].
pub color: String,
pub tint: f32,
/// Width: column (true) or text (false).
@@ -407,7 +411,7 @@ pub struct Rule {
impl Default for Rule {
fn default() -> Self {
Rule { on: false, weight: 1.0, color: "[Black]".into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
}
}
+2 -2
View File
@@ -321,7 +321,7 @@ pub struct TextFrameOptions {
pub first_baseline_min: f64,
pub ignore_wrap: bool,
pub auto_size: AutoSize,
/// Reference point (0..9) that stays fixed when auto-sizing.
/// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing.
pub auto_size_ref: u8,
pub column_rule: bool,
pub column_rule_weight: f64,
@@ -374,7 +374,7 @@ impl Default for TextFrameOptions {
first_baseline_min: 0.0,
ignore_wrap: false,
auto_size: AutoSize::Off,
auto_size_ref: 1,
auto_size_ref: 4,
column_rule: false,
column_rule_weight: 1.0,
column_rule_color: designcraft_color::swatch::BLACK.into(),
+1 -7
View File
@@ -134,7 +134,7 @@ impl Default for DocumentGrid {
}
/// A named numbered list.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NumberedList {
pub name: String,
@@ -142,12 +142,6 @@ pub struct NumberedList {
pub continue_across_stories: bool,
}
impl Default for NumberedList {
fn default() -> Self {
NumberedList { name: String::new(), continue_across_stories: true }
}
}
/// A conditional-text condition: its indicator colour (screen only) and whether text with it shows.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+21
View File
@@ -288,6 +288,27 @@ mod tests {
assert!(!designcraft_doc::ParaProps::default().keep_all_lines);
}
/// Format 1 wrote paragraph rules, text frame options and numbered lists in full, so their
/// old defaults (black rules, auto-size from the top centre, lists continuing across stories)
/// load as saved. New documents take InDesign's.
#[test]
fn format_1_rules_frames_and_lists_load_as_saved() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let old_rule = designcraft_doc::Rule { on: true, color: designcraft_doc::color::swatch::BLACK.into(), ..Default::default() };
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "Ruled", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().paras[0].para.rule_above = Some(old_rule.clone());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.auto_size_ref = 1;
d.settings.lists.push(designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: true });
let back = load(&save_v1(&d)).unwrap();
assert_eq!(back.story(sid).unwrap().paras[0].para.rule_above, Some(old_rule));
assert_eq!(back.item(fid).unwrap().text_frame().unwrap().options.auto_size_ref, 1);
assert!(back.settings.lists[0].continue_across_stories);
assert_eq!(designcraft_doc::Rule::default().color, designcraft_doc::TEXT_COLOR);
assert_eq!(designcraft_doc::TextFrameOptions::default().auto_size_ref, 4);
assert!(!designcraft_doc::NumberedList::default().continue_across_stories);
}
#[test]
fn rejects_garbage_and_newer_versions() {
assert!(load(b"nope").is_err());
+8 -1
View File
@@ -1201,6 +1201,9 @@ impl<'a> Ex<'a> {
if let Some(v) = a.single_word_justify {
el.set("SingleWordJustification", names::align_out(v));
}
if let Some(v) = a.kashidas {
el.set("Kashidas", names::kashidas_out(v));
}
n!(keep_with_next, "KeepWithNext");
b!(keep_lines_together, "KeepLinesTogether");
b!(keep_all_lines, "KeepAllLinesTogether");
@@ -1291,7 +1294,11 @@ impl<'a> Ex<'a> {
el.set(&format!("{k}Offset"), num(r.offset));
el.set(&format!("{k}LeftIndent"), num(r.left_indent));
el.set(&format!("{k}RightIndent"), num(r.right_indent));
props.push(p(&format!("{k}Color"), "object", self.sw(&r.color)));
props.push(if r.color == designcraft_doc::TEXT_COLOR {
p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR)
} else {
p(&format!("{k}Color"), "object", self.sw(&r.color))
});
}
fn stroke_attrs(&self, el: &mut El, s: &designcraft_doc::Stroke) {
+30 -36
View File
@@ -161,9 +161,9 @@ fn parse_point(s: Option<&str>) -> Option<Point> {
/// InDesign's corner size where a file gives a corner shape without one.
const INDESIGN_CORNER_SIZE: f64 = 12.0;
/// IDML tint (percent, `-1` = default) → 0..1.
/// IDML tint (percent; `-1` = the colour's own tint, 100 %) → 0..1.
fn tint(v: Option<f64>) -> Option<f32> {
v.map(|t| if t < 0.0 { 1.0 } else { (t / 100.0) as f32 })
v.map(|t| if t < 0.0 || t.is_nan() { 1.0 } else { (t / 100.0).min(1.0) as f32 })
}
impl<'r> Importer<'r> {
@@ -694,7 +694,7 @@ impl<'r> Importer<'r> {
let name = e.get("Name").unwrap_or("").to_string();
if !name.is_empty() && !name.starts_with("$ID/") && !self.lists.iter().any(|l| l.name == name) {
self.lists
.push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") != Some("false") });
.push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") == Some("true") });
}
}
let mut paras: Vec<(String, El)> = Vec::new();
@@ -808,18 +808,7 @@ impl<'r> Importer<'r> {
}
ts.border = Some(b);
}
if let Some(first) = e.num("StartRowFillCount").filter(|n| *n > 0.0) {
ts.alt_rows = Some(AltFills {
first: first as u32,
first_color: e.get("StartRowFillColor").map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
first_tint: tint(e.num("StartRowFillTint")).unwrap_or(1.0),
next: e.num("EndRowFillCount").unwrap_or(1.0) as u32,
next_color: e.get("EndRowFillColor").map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
next_tint: tint(e.num("EndRowFillTint")).unwrap_or(1.0),
skip_first: 0,
skip_last: 0,
});
}
ts.alt_rows = self.alt_fills(e, "Row").map(|a| AltFills { skip_first: 0, skip_last: 0, ..a });
ts.space_before = e.num("SpaceBefore");
ts.space_after = e.num("SpaceAfter");
match self.styles.table.iter_mut().find(|c| c.name == name) {
@@ -966,7 +955,7 @@ impl<'r> Importer<'r> {
o.span_columns = e.boolean("EnableStraddling").unwrap_or(false);
o.rule.on = e.boolean("RuleOn").unwrap_or(true);
o.rule.weight = e.num("RuleLineWeight").unwrap_or(1.0);
o.rule.tint = (e.num("RuleTint").unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
o.rule.tint = tint(e.num("RuleTint")).unwrap_or(1.0);
o.rule.left_indent = e.num("RuleLeftIndent").unwrap_or(0.0);
o.rule.width = e.num("RuleWidth").unwrap_or(72.0);
o.rule.offset = e.num("RuleOffset").unwrap_or(0.0);
@@ -1040,7 +1029,7 @@ impl<'r> Importer<'r> {
for k in ["Underline", "StrikeThru"] {
let num = |n: &str| e.num(&format!("{k}{n}")).filter(|v| *v > -9000.0);
let color = e.prop(&format!("{k}Color")).map(|r| self.swatch_ref(r.trim())).filter(|r| r != "Text Color" && !r.is_empty());
let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32);
let tint = tint(e.num(&format!("{k}Tint")));
if k == "Underline" {
a.underline_weight = num("Weight").map(Some);
a.underline_offset = num("Offset").map(Some);
@@ -1193,6 +1182,7 @@ impl<'r> Importer<'r> {
a.glyph_scale_max = frac("MaximumGlyphScaling");
a.auto_leading = frac("AutoLeading");
a.single_word_justify = e.prop("SingleWordJustification").and_then(|v| names::align_in(v.trim()));
a.kashidas = e.prop("Kashidas").and_then(|v| names::kashidas_in(v.trim()));
a.keep_with_next = u("KeepWithNext");
a.keep_lines_together = e.boolean("KeepLinesTogether");
a.keep_all_lines = e.boolean("KeepAllLinesTogether");
@@ -1258,7 +1248,9 @@ impl<'r> Importer<'r> {
}
if let Some(c) = e.prop(&format!("{k}Color")) {
let c = c.trim();
if c.contains('/') {
if c == designcraft_doc::TEXT_COLOR {
r.color = c.to_string();
} else if c.contains('/') {
r.color = self.swatch_ref(c);
}
}
@@ -1386,24 +1378,8 @@ impl<'r> Importer<'r> {
t.options.space_after = e.num("SpaceAfter").unwrap_or(t.options.space_after);
t.options.repeat_header = e.get("HeaderBehavior") != Some("RepeatOnce");
t.options.repeat_footer = e.get("FooterBehavior") != Some("RepeatOnce");
for kind in ["Row", "Column"] {
let Some(first) = e.num(&format!("Start{kind}FillCount")).filter(|n| *n > 0.0) else { continue };
let alt = AltFills {
first: first as u32,
first_color: e.get(&format!("Start{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
first_tint: tint(e.num(&format!("Start{kind}FillTint"))).unwrap_or(1.0),
next: e.num(&format!("End{kind}FillCount")).unwrap_or(1.0) as u32,
next_color: e.get(&format!("End{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
next_tint: tint(e.num(&format!("End{kind}FillTint"))).unwrap_or(1.0),
skip_first: e.num(&format!("SkipFirstAlternatingFill{kind}s")).unwrap_or(0.0) as u32,
skip_last: e.num(&format!("SkipLastAlternatingFill{kind}s")).unwrap_or(0.0) as u32,
};
if kind == "Row" {
t.options.alt_rows = Some(alt);
} else {
t.options.alt_cols = Some(alt);
}
}
t.options.alt_rows = self.alt_fills(e, "Row");
t.options.alt_cols = self.alt_fills(e, "Column");
t.style = e
.get("AppliedTableStyle")
.map(|r| names::style_name_in(names::TABLE_BUILTINS, &unescape_id(r.trim_start_matches("TableStyle/"))))
@@ -1460,6 +1436,24 @@ impl<'r> Importer<'r> {
t
}
/// A table's or table style's alternating `kind` ("Row" or "Column") fills, when it has any;
/// unset values are InDesign's: the first Black 20 %, the next None.
fn alt_fills(&mut self, e: &El, kind: &str) -> Option<AltFills> {
let first = e.num(&format!("Start{kind}FillCount")).filter(|n| *n > 0.0)?;
let d = AltFills::default();
let count = |k: String, or: u32| e.num(&k).map_or(or, |v| v.max(0.0) as u32);
Some(AltFills {
first: first as u32,
first_color: e.get(&format!("Start{kind}FillColor")).map_or(d.first_color, |c| self.swatch_ref(c)),
first_tint: tint(e.num(&format!("Start{kind}FillTint"))).unwrap_or(d.first_tint),
next: count(format!("End{kind}FillCount"), d.next),
next_color: e.get(&format!("End{kind}FillColor")).map_or(d.next_color, |c| self.swatch_ref(c)),
next_tint: tint(e.num(&format!("End{kind}FillTint"))).unwrap_or(d.next_tint),
skip_first: count(format!("SkipFirstAlternatingFill{kind}s"), 0),
skip_last: count(format!("SkipLastAlternatingFill{kind}s"), 0),
})
}
fn walk_story(&mut self, e: &El, b: &mut StoryBuilder, pf: &ParaFormat, pchars: &CharAttrs, cf: &CharFormat, brk: Option<&str>) {
for n in &e.children {
match n {
+8
View File
@@ -447,6 +447,14 @@ pub fn auto_size_in(s: &str) -> AutoSize {
.find(|m| auto_size_out(*m) == s)
.unwrap_or(AutoSize::Off)
}
/// Insert Kashidas (`Kashidas`): InDesign's default, or off.
pub fn kashidas_out(on: bool) -> &'static str {
if on { "DefaultKashidas" } else { "KashidasOff" }
}
pub fn kashidas_in(s: &str) -> Option<bool> {
[true, false].into_iter().find(|on| kashidas_out(*on) == s)
}
/// Auto-size reference points in our 0..9 order (row-major, top-left first).
pub const REF_POINTS: [&str; 9] = [
"TopLeftPoint",
+134 -2
View File
@@ -28,6 +28,20 @@ fn zip_files_plain() -> Vec<u8> {
w.finish().unwrap().into_inner()
}
/// The text of every part of the package `bytes` whose name starts with `prefix`.
fn zip_text(bytes: &[u8], prefix: &str) -> String {
use std::io::Read;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = String::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
if f.name().starts_with(prefix) {
f.read_to_string(&mut out).unwrap();
}
}
out
}
/// A hand-written, minimal IDML document: one facing-pages spread with a right page, a
/// threaded story across two frames, a tint and a grouped paragraph style.
const DESIGNMAP: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@@ -294,10 +308,19 @@ fn indesign_text_defaults() -> Vec<AttrRow> {
"StartParagraph": "Anywhere" => StartParagraph::Anywhere, "NextColumn" => StartParagraph::NextColumn, |r| r.0.start_paragraph;
"BulletsAndNumberingListType": "NoList" => ListType::None, "BulletList" => ListType::Bullets, |r| r.0.list_type;
"BalanceRaggedLines": "NoBalancing" => false, "FullyBalanced" => true, |r| r.0.balance_ragged;
"Kashidas": "DefaultKashidas" => true, "KashidasOff" => false, |r| r.0.kashidas;
"RuleAbove": "false" => false, "true" => true, |r| r.0.rule_above.on;
"RuleAboveColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_above.color;
"RuleAboveTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_above.tint;
"RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on;
"RuleBelowColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_below.color;
"RuleBelowTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.tint;
"ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on;
"ParagraphShadingColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.shading_color;
"ParagraphShadingTint": "20" => 0.2f32, "50" => 0.5f32, |r| r.0.shading_tint;
"ParagraphBorderOn": "false" => false, "true" => true, |r| r.0.border_on;
"ParagraphBorderColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.border_color;
"ParagraphBorderTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.border_tint;
"FontStyle": "Regular" => "Regular", "Bold" => "Bold", |r| r.1.font_style;
"PointSize": "12" => 12.0, "9" => 9.0, |r| r.1.size;
"Leading": "Auto" => Leading::Auto, "14" => Leading::Points(14.0), |r| r.1.leading;
@@ -316,6 +339,8 @@ fn indesign_text_defaults() -> Vec<AttrRow> {
"Position": "Normal" => Position::Normal, "Superscript" => Position::Superscript, |r| r.1.position;
"Underline": "false" => false, "true" => true, |r| r.1.underline;
"StrikeThru": "false" => false, "true" => true, |r| r.1.strikethrough;
"UnderlineTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.1.underline_tint;
"StrikeThruTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.1.strikethrough_tint;
"Ligatures": "true" => true, "false" => false, |r| r.1.ligatures;
"NoBreak": "false" => false, "true" => true, |r| r.1.no_break;
"OTFContextualAlternate": "true" => true, "false" => false, |r| designcraft_doc::otf::is_on(&r.1.otf_features, "calt");
@@ -368,6 +393,101 @@ fn absent_text_attributes_take_indesign_defaults() {
assert_eq!(omitted.1.font_family, designcraft_doc::CharProps::default().font_family);
}
/// A tint of -1 is the colour's own tint (100 %): written on a style, it replaces its parent's.
/// Tints are never negative or above 100 %.
#[test]
fn minus_one_tint_is_the_colours_own() {
let tints = |v: &str| {
["UnderlineTint", "StrikeThruTint", "FillTint", "StrokeTint", "ParagraphShadingTint", "ParagraphBorderTint", "RuleAboveTint", "RuleBelowTint"]
.map(|k| format!(r#"{k}="{v}""#))
.join(" ")
+ r#" RuleAbove="true" RuleBelow="true""#
};
for (body, want) in [("-1", 1.0), ("150", 1.0)] {
let r = resolve_body(&tints("50"), &tints(body));
let got = [
r.1.underline_tint,
r.1.strikethrough_tint,
r.1.fill_tint,
r.1.stroke_tint,
r.0.shading_tint,
r.0.border_tint,
r.0.rule_above.tint,
r.0.rule_below.tint,
];
assert_eq!(got, [want; 8], "{body}");
}
// Footnote rules, table fills and strokes, cell fills.
let prefs = PREFS.replace("</idPkg:Preferences>", r#"<FootnoteOption RuleTint="-1"/></idPkg:Preferences>"#);
let story = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table Self="tb" StartRowFillCount="1" StartRowFillTint="-1" EndRowFillTint="-1" TopBorderStrokeTint="-1">
<Row Self="r0" Name="0"/><Column Self="c0" Name="0"/><Cell Self="ce" Name="0:0" FillColor="Color/Black" FillTint="-1" TopEdgeStrokeTint="-1"/></Table></CharacterStyleRange></ParagraphStyleRange></Story>
</idPkg:Story>"#;
let d = import_idml_with(
&zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", &prefs),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", story),
]),
&|_| None,
)
.unwrap();
assert_eq!(d.footnote_options.rule.tint, 1.0);
let t = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
let alt = t.options.alt_rows.as_ref().unwrap();
assert_eq!((alt.first_tint, alt.next_tint, t.options.border.tint), (1.0, 1.0, 1.0));
let c = t.cell(0, 0).unwrap();
assert_eq!((c.fill_tint, c.strokes[0].tint), (1.0, 1.0));
}
/// Rules in Text Color take the colour of the paragraph's text; they round-trip as Text Color.
#[test]
fn text_color_rules_round_trip() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let rule = designcraft_doc::Rule { on: true, ..Default::default() };
assert_eq!(rule.color, designcraft_doc::TEXT_COLOR);
let para = designcraft_doc::ParaAttrs {
rule_above: Some(rule.clone()),
rule_below: Some(designcraft_doc::Rule { color: "[Black]".into(), ..rule }),
kashidas: Some(false),
..Default::default()
};
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Ruled", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().paras[0].para = para.clone();
let bytes = export_idml(&d);
let story = zip_text(&bytes, "Stories/");
assert!(story.contains(r#"<RuleAboveColor type="string">Text Color</RuleAboveColor>"#), "{story}");
assert!(story.contains(r#"Kashidas="KashidasOff""#), "{story}");
let back = import_idml(&bytes).unwrap();
let p = &back.stories.values().find(|s| s.text == "Ruled").unwrap().paras[0];
assert_eq!((p.para.rule_above.as_ref(), p.para.rule_below.as_ref()), (para.rule_above.as_ref(), para.rule_below.as_ref()));
assert_eq!(p.para.kashidas, Some(false));
}
/// `[Basic Paragraph]` is InDesign's `$ID/NormalParagraphStyle`, both ways.
#[test]
fn basic_paragraph_is_normal_paragraph_style() {
let d = import_idml_with(&fixture(), &|_| None).unwrap();
let basic = d.styles.para(st::BASIC_PARAGRAPH).unwrap();
assert_eq!(basic.based_on.as_deref(), Some(designcraft_doc::NO_PARA_STYLE));
assert_eq!(d.styles.para("Text/Body").unwrap().based_on.as_deref(), Some(st::BASIC_PARAGRAPH));
let s = d.stories.values().find(|s| s.text.starts_with("Hello")).unwrap();
assert_eq!(s.paras[1].style, st::BASIC_PARAGRAPH);
assert!(d.styles.paragraph.iter().all(|p| !p.name.contains("NormalParagraphStyle")));
let bytes = export_idml(&d);
let styles = zip_text(&bytes, "Resources/Styles.xml");
assert!(styles.contains(r#"Self="ParagraphStyle/$ID/NormalParagraphStyle" Name="$ID/NormalParagraphStyle""#), "{styles}");
assert!(zip_text(&bytes, "Stories/").contains(r#"AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle""#));
let back = import_idml(&bytes).unwrap();
assert_eq!(back.styles.paragraph.iter().filter(|p| p.name == st::BASIC_PARAGRAPH).count(), 1);
}
/// Page items, frames, tables and preferences an IDML file leaves unset are InDesign's defaults
/// for a new document.
#[test]
@@ -386,7 +506,9 @@ fn absent_item_and_document_attributes_take_indesign_defaults() {
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d">
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table Self="tb"><Row Self="r0" Name="0"/><Column Self="c0" Name="0"/><Cell Self="ce" Name="0:0"/></Table></CharacterStyleRange></ParagraphStyleRange></Story>
<NumberingList Self="NumberingList/Steps" Name="Steps"/>
<RootTableStyleGroup Self="rts"><TableStyle Self="TableStyle/Banded" Name="Banded" StartRowFillCount="1"/></RootTableStyleGroup>
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table Self="tb" StartRowFillCount="1" StartColumnFillCount="2"><Row Self="r0" Name="0"/><Column Self="c0" Name="0"/><Cell Self="ce" Name="0:0"/></Table></CharacterStyleRange></ParagraphStyleRange></Story>
<Spread Self="sp1"><Page Self="p1"/>
<Rectangle Self="plain">{}</Rectangle>
<Rectangle Self="round" TopLeftCornerOption="RoundedCorner" TopRightCornerOption="RoundedCorner" BottomLeftCornerOption="RoundedCorner" BottomRightCornerOption="RoundedCorner">{}</Rectangle>
@@ -432,12 +554,22 @@ fn absent_item_and_document_attributes_take_indesign_defaults() {
let o = &tf.text_frame().unwrap().options;
assert_eq!((o.columns, o.gutter, o.inset, o.balance_columns, o.ignore_wrap), (1, 12.0, [0.0; 4], false, false));
assert_eq!((o.vertical_justification, o.first_baseline, o.first_baseline_min), (VerticalJustification::Top, FirstBaseline::Ascent, 0.0));
assert_eq!(o.auto_size, designcraft_doc::AutoSize::Off);
assert_eq!((o.auto_size, o.auto_size_ref), (designcraft_doc::AutoSize::Off, 4), "auto-size from the centre");
// Numbered lists don't continue across stories.
assert_eq!(d.settings.lists, vec![designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: false }]);
// Table and cell options.
let t = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert_eq!((t.options.space_before, t.options.space_after, t.options.repeat_header), (4.0, -4.0, true));
assert_eq!((t.options.border.weight, t.options.border.color.as_str()), (1.0, "[Black]"));
assert_eq!((t.rows[0].mode, t.rows[0].height), (designcraft_doc::RowHeightMode::AtLeast, 3.0));
// Alternating fills: the first rows or columns Black 20 %, the next None.
let fills = |a: &designcraft_doc::AltFills| (a.first_color.clone(), a.first_tint, a.next_color.clone(), a.next_tint, a.next);
let want = ("[Black]".to_string(), 0.2f32, "[None]".to_string(), 1.0f32, 1);
assert_eq!(fills(t.options.alt_rows.as_ref().unwrap()), want);
let cols = t.options.alt_cols.as_ref().unwrap();
assert_eq!((fills(cols), cols.first), (want.clone(), 2));
let banded = d.styles.table.iter().find(|s| s.name == "Banded").unwrap();
assert_eq!(fills(banded.alt_rows.as_ref().unwrap()), want);
let c = t.cell(0, 0).unwrap();
assert_eq!((c.insets, c.vj, c.fill.as_str()), ([4.0; 4], VerticalJustification::Top, "[None]"));
for e in &c.strokes {