diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a67102f5d0..a01f631638 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4191,6 +4191,57 @@ fn standard_vertical_roman_alignment_comes_back() { assert_eq!(flags(&texts(&read_back(&both))[0])[2], (false, true)); } +/// Insert Aki Left and Right come back on each run, in vertical and horizontal type: a run with +/// either writes it as key 49 or 50 in 1/1000 em, a run without writes neither, and nothing is +/// reported. Each object lays out as it did. A run that also has an attribute the text document +/// leaves out is still reported. +#[test] +fn insert_aki_comes_back() { + use vectorcraft_doc::{CharStyle, TextObject, TextRun}; + let plain = style(14.0, Color::BLACK); + let left = CharStyle { aki_left: 250.0, ..plain.clone() }; + let both = CharStyle { aki_left: 125.5, aki_right: 500.0, ..plain.clone() }; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let mut down = TextObject::point(vectorcraft_geom::Point::new(150.0, 20.0), "", plain.clone()); + down.runs = vec![TextRun::new("AB", left.clone()), TextRun::new("CD", plain.clone()), TextRun::new("EF", both.clone())]; + down.vertical = true; + let mut across = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "", plain.clone()); + across.runs = vec![TextRun::new("GH", both.clone()), TextRun::new("IJ", plain.clone())]; + for t in [down, across] { + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&type_lines(&back), &type_lines(&doc)); + let texts = |d: &Document| -> Vec { + d.layers[0].children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).collect() + }; + let aki = |t: &TextObject| t.runs.iter().map(|r| (r.style.aki_left, r.style.aki_right)).collect::>(); + let (written, read) = (texts(&doc), texts(&back)); + assert_eq!(read.iter().map(aki).collect::>(), [vec![(250.0, 0.0), (0.0, 0.0), (125.5, 500.0)], vec![(125.5, 500.0), (0.0, 0.0)]]); + let origins = |t: &TextObject| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| (r3(g.origin.x), r3(g.origin.y))).collect::>() + }; + for (w, r) in written.iter().zip(&read) { + assert_eq!(origins(r), origins(w), "vertical={}", w.vertical); + } + let decoded = text_document(&program(&doc)); + for (key, count) in [("/49 250.0", 1), ("/49 125.5", 2), ("/50 500.0", 2)] { + assert_eq!(decoded.matches(key).count(), count, "{key}: {decoded}"); + } + assert_eq!(decoded.matches("/49 ").count() + decoded.matches("/50 ").count(), 5, "{decoded}"); + // Underline is still left out, and reported. + let mut lossy = doc.clone(); + let id = lossy.layers[0].children().unwrap()[0].id; + if let Some(NodeKind::Text(t)) = lossy.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.underline = true; + } + assert_eq!(export_ai_editing(&lossy, &opts()).unwrap().warnings, [writer::TYPE_ATTRIBUTES]); +} + /// The text document of program `text` (LF line ends), decoded, with its UTF-16 strings' zero /// bytes left out. fn text_document(text: &str) -> String { diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 885af725b7..2e90817b98 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -20,6 +20,7 @@ use std::fmt::Write; use kurbo::{ParamCurveArclen, PathSeg}; use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::MAX_AKI; use vectorcraft_doc::{ AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, LineCap, LineJoin, Mojikumi, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject, VerticalAlign, @@ -29,7 +30,7 @@ use vectorcraft_geom::{Affine, PathData, Point}; use crate::ps; /// What the text document holds of type, and what it leaves out. -pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and its weight, cap, join and miter limit, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps"; +pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and its weight, cap, join and miter limit, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, Insert Aki, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps"; /// The version of the text document's form (`/98 << /0 n >>`), as the newest files users sent have it. const STREAM_VERSION: u32 = 14; @@ -326,6 +327,12 @@ fn end_read_as_none(t: &TextObject) -> bool { !closed(&segs) && end < 1.0 - 1e-9 && place_on(&segs, end) >= segs.len() as f64 - 0.02 } +/// Insert Aki Left or Right `v` as the text document holds it: 0 to [`MAX_AKI`] in 1/1000 em, and 0 +/// for a value that is not a number. +fn held_aki(v: f64) -> f64 { + if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) } +} + /// Has type `t` attributes the text document leaves out ([`TYPE_ATTRIBUTES`])? pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // Small capitals are read back at Document Setup's default size. @@ -360,6 +367,8 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { tate_chu_yoko: s.tate_chu_yoko, // Key 35 holds one of the two: a Tate-chu-yoko run is read back without the other. standard_vertical_roman: s.standard_vertical_roman && !s.tate_chu_yoko, + aki_left: held_aki(s.aki_left), + aki_right: held_aki(s.aki_right), ..CharStyle::default() } }; @@ -677,6 +686,14 @@ impl TextDoc { }; d.push((12, V::Int(caps))); d.push((35, V::Int(baseline_direction(st)))); + // Insert Aki Left and Right in 1/1000 em, as the reader reads keys 49 and 50. No key is no + // space added: neither the stories' character attributes nor `[Normal Character Style]` set one. + for (key, v) in [(49, st.aki_left), (50, st.aki_right)] { + let v = held_aki(v); + if v > 0.0 { + d.push((key, real(v))); + } + } let fill = solid(&st.fill).flatten(); // A stroke of no width keeps its paint. let stroke = solid(&st.stroke).flatten(); diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 28994527a7..a45c203d75 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -25,6 +25,7 @@ use std::collections::{BTreeMap, HashMap}; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::MAX_AKI; use vectorcraft_doc::{ Burasagari, CharStyle, HyphenationOptions, JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LineCap, LineJoin, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, @@ -525,6 +526,17 @@ struct Style { /// Key 35 at 1 (Standard): Standard Vertical Roman Alignment, half-width characters upright in /// a column. Any other value, or no key in the run, its character style or the defaults, is off. standard_vertical_roman: bool, + /// Keys 49 and 50, Insert Aki Left and Right ([`CharStyle::aki_left`]), in 1/1000 em. + aki_left: f64, + aki_right: f64, +} + +/// Insert Aki Left or Right as key `v` of a character style gives it, in 1/1000 em (the unit the +/// scripting reference gives `aki left` and `aki right`), 0 to [`MAX_AKI`]. A negative value, one +/// that is not a finite number, or no key is 0: no space added. A key the run itself carries wins +/// over its styles' even when it is not a number, so a bad value never shows a style's. +fn aki(v: Option<&Val>) -> f64 { + v.and_then(Val::num).filter(|v| v.is_finite() && *v > 0.0).map_or(0.0, |v| v.min(MAX_AKI)) } /// The longest font version string read from a font record: name ID 5 strings are short (`Version @@ -922,6 +934,8 @@ impl Texts { caps: num("12").filter(|v| *v == 1.0 || *v == 2.0).map_or(0, |v| v as u8), tate_chu_yoko: num("35") == Some(3.0), standard_vertical_roman: num("35") == Some(1.0), + aki_left: aki(pick("49")), + aki_right: aki(pick("50")), } } } @@ -1549,6 +1563,9 @@ fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { small_caps: (s.caps == 1).then(|| vectorcraft_doc::DocSetup::default().small_caps_size), tate_chu_yoko: s.tate_chu_yoko, standard_vertical_roman: s.standard_vertical_roman, + // In ems of the type's own size, so the art's scale does not apply. + aki_left: s.aki_left, + aki_right: s.aki_right, ..CharStyle::default() } } @@ -1922,6 +1939,48 @@ mod tests { assert!(texts(b"/35 3").style(None).tate_chu_yoko && !texts(b"/35 3").style(None).standard_vertical_roman); } + /// Keys 49 and 50 of a character style are Insert Aki Left and Right in 1/1000 em, each from the + /// run, its character style and the styles that is based on, `[Normal Character Style]`, then the + /// document's defaults. A run's own 0 wins over a style's value, a value past 1000 is 1000, and a + /// negative value, one that is not a number or no key at all is 0. + #[test] + fn a_run_keeps_its_insert_aki() { + let texts = |defaults: &[u8], normal: &[u8]| Texts { + stories: vec![], + fonts: vec![], + defaults: read(defaults).unwrap(), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![ + read(&[&b"/0 ([Normal Character Style]) /6 << "[..], normal, b" >>"].concat()).unwrap(), + read(b"/0 (Base) /6 << /49 125.0 >>").unwrap(), + read(b"/0 (Spaced) /5 1 /6 << /50 500.0 >>").unwrap(), + ], + para_sheets: vec![], + frames: vec![], + patterns: HashMap::new(), + kinsoku_sets: vec![], + }; + let aki = |defaults: &[u8], normal: &[u8], run: &[u8]| { + let s = texts(defaults, normal).style(Some(&read(run).unwrap())); + (s.aki_left, s.aki_right) + }; + assert_eq!(aki(b"/0 0", b"", b"/6 << /49 250.0 /50 750.0 >>"), (250.0, 750.0), "the run's own"); + assert_eq!(aki(b"/0 0", b"", b"/5 2 /6 << >>"), (125.0, 500.0), "its style's, and the style that is based on"); + assert_eq!(aki(b"/0 0", b"", b"/5 2 /6 << /49 0.0 /50 0 >>"), (0.0, 0.0), "the run's own zero wins"); + assert_eq!(aki(b"/49 10.0 /50 20.0", b"/49 30.0", b"/6 << >>"), (30.0, 20.0), "Normal, then the document's"); + assert_eq!(aki(b"/49 10.0 /50 20.0", b"", b"/6 << >>"), (10.0, 20.0), "the document's"); + assert_eq!(aki(b"/0 0", b"", b"/6 << >>"), (0.0, 0.0), "no key"); + assert_eq!(aki(b"/0 0", b"", b"/6 << /49 1000.5 /50 1e300 >>"), (MAX_AKI, MAX_AKI), "clamped"); + for bad in [&b"-1.0"[..], b"-250", b"(125)", b"true", b"[ 125 ]", b"1e400", b"-1e400"] { + let run = [&b"/5 2 /6 << /49 "[..], bad, b" /50 ", bad, b" >>"].concat(); + assert_eq!(aki(b"/0 0", b"", &run), (0.0, 0.0), "{}", String::from_utf8_lossy(bad)); + } + // In ems of the type's size: the art's scale leaves it as it is. + let s = texts(b"/0 0", b"").style(Some(&read(b"/6 << /49 250.0 /50 125.0 >>").unwrap())); + let c = char_style(&s, 10.0, Affine::IDENTITY); + assert_eq!((c.aki_left, c.aki_right), (250.0, 125.0)); + } + /// Keys 61, 62 and 64 of a character style are its stroke's cap (0 butt, 1 round, 2 /// projecting), join (0 miter, 1 round, 2 bevel) and miter limit, from the run, its character /// style or the document's defaults. A value that isn't one of theirs keeps the default, a miter @@ -2397,6 +2456,8 @@ mod tests { caps: 0, tate_chu_yoko: false, standard_vertical_roman: false, + aki_left: 0.0, + aki_right: 0.0, }, )], kerns: vec![], diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 7e38b5ba51..76980f4b7e 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -101,7 +101,7 @@ const HEX_LINE: usize = 60; pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)"; pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines"; pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)"; -pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, optical kerning, small caps, superscript and subscript, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out"; +pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, optical kerning, small caps, superscript and subscript, Insert Aki, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out"; pub const DYNAMIC_TEXT: &str = "Dynamic Text is written as the sizes the type is drawn at (legacy .ai files don't have it)"; pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)"; diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index 7ebae72123..d971e5249e 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -123,6 +123,9 @@ pub(super) fn lost_attributes(t: &TextObject) -> bool { || s.optical_kerning || s.small_caps.is_some() || s.position != CharPosition::Normal + // Its text operators (§15.4) have no Insert Aki. + || s.aki_left != 0.0 + || s.aki_right != 0.0 || !s.features.is_empty() || !s.font_axes.is_empty() }; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 663e21bec6..ad27eb4a03 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1435,6 +1435,41 @@ fn variable_font_settings_are_warned_as_left_out() { assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); } +/// A legacy `.ai` text object has no Insert Aki (§15.4): type with Insert Aki Left or Right is +/// written as text with the warning that it is left out. Written as outlines, the glyphs keep the +/// space it adds, and nothing is reported. +#[test] +fn insert_aki_is_warned_as_left_out_of_text() { + for (left, right) in [(250.0, 0.0), (0.0, 125.0)] { + let mut doc = typed(); + let id = texts(&doc)[0].0.id; + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.aki_left = left; + t.runs[0].style.aki_right = right; + } + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES), "{left} {right}: {:?}", out.warnings); + assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); + } + let width = |aki: f64| { + let mut st = style(20.0, red()); + st.aki_left = aki; + st.aki_right = aki; + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "HH", st); + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }); + assert!(!out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES), "{:?}", out.warnings); + let back = crate::import(&out.bytes).unwrap().document; + children(&children(&back.layers[0])[0])[0].visual_bounds().unwrap().width() + }; + // Between the two H's: 0.5 em after the first and 0.5 em before the second, 20 pt. + let (plain, spaced) = (width(0.0), width(500.0)); + assert!((spaced - plain - 20.0).abs() < 0.05, "{plain} {spaced}"); +} + /// Standard Vertical Roman Alignment is written as `Tv` and Tate-chu-yoko in horizontal type as a /// Kumi block recorded for later use, `TY` (§15.3.4), each where it changes: the marks read back /// on the same characters, a block is closed before its text object ends, and type with neither diff --git a/docs/mcp.md b/docs/mcp.md index f58ef2eb3a..9b9dc49ae4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9073,7 +9073,11 @@ menus, and a value typed past 1000 sets 1000; Reset Panel sets both to 0. Charac in Advanced Character Formats. With Fractional Widths off or System Layout on, each side is set in whole points, and justification keeps that width. Live SVG text places each character the space moves, and type on a path with Insert Aki is written as outlines. An SVG saved without Preserve Editing (`preserveEditing`) reopens without the -Insert Aki values; save it with Preserve Editing to keep them editable. +Insert Aki values; save it with Preserve Editing to keep them editable. An `.ai` file's text document keeps both +values, and opening the file reads them back. The stored values are read in thousandths of an em and a negative +value opens as 0; these are VectorCraft's own rules. When a legacy `.ai` save writes type as text, it writes type with +Insert Aki without the values and warns that they are left out. With `outlineText: true` it writes the type as outlines, +which keep the space Insert Aki adds, and does not warn that the values are left out. ```json {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"akiRight":500}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cf67a5943f..2f95ad9d1b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1451,6 +1451,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. Character panel › Insert Aki Left and Insert Aki Right (with the East Asian options; `text.setFormat` or `text.setRangeStyle {akiLeft, akiRight}`, thousandths of an em from 0 to 1000, 0 by default) add space before and after each selected character, measured as tracking is, and kept as set through justification ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); before and after follow the reading order in vertical and right-to-left type, character styles carry both values (Character Style Options › Advanced Character Formats edits them), and a file holding a value outside the range opens with the nearer end. + Insert Aki Left and Right open from an `.ai` file's text document (read in thousandths of an em, the unit the [scripting reference](https://web.archive.org/web/20160410092022/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/pdf/illustrator/pdfs/illustrator_scripting_reference_applescript.pdf#page=90) gives `aki left` and `aki right`; VectorCraft's own rule for the stored value) from the run, its character style, the styles that one is based on, `[Normal Character Style]` and the document's defaults in that order. A negative value or one that is not a number opens as 0, and a value past 1000 opens as 1000. An `.ai` save writes each run's nonzero values in the form that reader reads. A legacy `.ai` save writes type with Insert Aki as text without it and reports that it is left out; type it writes as outlines keeps the space. Character panel › Tsume (with the East Asian options; `text.setFormat` or `text.setRangeStyle {tsume}`) narrows the space around each selected character ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); by VectorCraft's own rule the value is a percentage from 0 to 100, 0 by default, of the blank space on each side of the character's ink that horizontal type takes away without changing the glyph's shape, with tracking, kerning and Insert Aki added after it. Character styles carry it (Character Style Options › Advanced Character Formats edits it), and a file holding a value outside the range opens with the nearer end. The Character panel's All Caps, Underline and Strikethrough buttons and its menu's All Caps, Underline, Strikethrough, Proportional Metrics, Tate-chu-yoko and No Break show off while only some of the selected characters have the attribute, and choosing one then turns it on for all of them, as `type.underline` (Cmd+Shift+U) does for underlining; the Character Alignment and Digits submenus check no item while the selected characters differ. `text.setFormat` refuses a value that is not true or false for its on/off attributes, as `text.setRangeStyle` does. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).