Skip to main content

ag_psd/
text.rs

1/*
2File: crates/ag-psd/src/text.rs
3
4Purpose:
5работа с текстовыми слоями (связывание текста со слоями и Engine Data).
6
7Source compatibility:
8- порт upstream-файла `test/ag-psd/src/text.ts` (разбиение 1:1).
9
10Main responsibilities:
11- `decodeEngineData(engineData)` -> `decode_engine_data(&EngineValue) -> LayerTextData`
12- `encodeEngineData(data)`       -> `encode_engine_data(&LayerTextData) -> EngineValue`
13- хелперы encode/decode стилей, цветов, шрифтов, дефолтные листы стилей.
14
15Архитектурное замечание:
16upstream `text.ts` оперирует ИСКЛЮЧИТЕЛЬНО над EngineData (нетипизированный
17JS-объект, у нас — `crate::engine_data::EngineValue`), а НЕ над дескрипторами.
18Поэтому `crate::descriptor` здесь не используется, и его «явная типизация
19вариантов» к этому файлу не относится. Все значения строятся как `EngineValue`.
20
21Friendly-типы (`LayerTextData`, `TextStyle`, `ParagraphStyle`, `Font`,
22`TextGridInfo`, `Color`, `AntiAlias`, `Justification`, `Orientation`,
23`TextShapeType`, `TextStyleRun`, `ParagraphStyleRun`) переиспользуются из
24`crate::psd` без локального переопределения. Структуры, специфичные для самого
25EngineData (FontSet/ParagraphSheet/StyleSheet и т.п.), не выделяются в типы —
26они существуют только как форма `EngineValue::Dict`, как и в upstream'е.
27
28Порядок ключей в строящихся словарях ВАЖЕН: сериализатор EngineData обходит
29ключи в порядке вставки, а вывод сверяется Photoshop'ом побайтово. Поэтому
30порядок ключей здесь в точности повторяет порядок object-literal'ов TS.
31
32Точность чисел: upstream хранит всё как JS `number` (f64). Здесь — `f64`.
33*/
34
35use crate::engine_data::EngineValue;
36use crate::psd::{
37    AntiAlias, Color, Cmyk, Font, Grayscale, Justification, LayerTextData, Orientation,
38    ParagraphStyle, ParagraphStyleRun, Rgb, Rgba, TextGridInfo, TextShapeType, TextStyle,
39    TextStyleRun,
40};
41
42// ===========================================================================
43// EngineValue access helpers (mirror loose JS property access)
44// ===========================================================================
45
46fn dict<'a>(v: &'a EngineValue) -> Option<&'a [(String, EngineValue)]> {
47    match v {
48        EngineValue::Dict(m) => Some(m),
49        _ => None,
50    }
51}
52
53/// `obj[key]` over a Dict. Returns `None` when not present (mirror `undefined`).
54fn get<'a>(v: &'a EngineValue, key: &str) -> Option<&'a EngineValue> {
55    dict(v).and_then(|m| m.iter().find(|(k, _)| k == key).map(|(_, val)| val))
56}
57
58fn as_number(v: &EngineValue) -> Option<f64> {
59    match v {
60        EngineValue::Number(n) => Some(*n),
61        _ => None,
62    }
63}
64
65fn as_bool(v: &EngineValue) -> Option<bool> {
66    match v {
67        EngineValue::Bool(b) => Some(*b),
68        _ => None,
69    }
70}
71
72fn as_str(v: &EngineValue) -> Option<&str> {
73    match v {
74        EngineValue::Str(s) => Some(s.as_str()),
75        _ => None,
76    }
77}
78
79fn as_array(v: &EngineValue) -> Option<&[EngineValue]> {
80    match v {
81        EngineValue::Array(a) => Some(a),
82        _ => None,
83    }
84}
85
86/// `!!value` truthiness for booleans coming out of EngineData.
87fn truthy_bool(v: Option<&EngineValue>) -> bool {
88    match v {
89        Some(EngineValue::Bool(b)) => *b,
90        Some(EngineValue::Number(n)) => *n != 0.0,
91        Some(EngineValue::Str(s)) => !s.is_empty(),
92        _ => false,
93    }
94}
95
96fn num_array(v: &EngineValue) -> Vec<f64> {
97    as_array(v)
98        .map(|a| a.iter().filter_map(as_number).collect())
99        .unwrap_or_default()
100}
101
102fn n(value: f64) -> EngineValue {
103    EngineValue::Number(value)
104}
105
106fn b(value: bool) -> EngineValue {
107    EngineValue::Bool(value)
108}
109
110fn num_arr(values: &[f64]) -> EngineValue {
111    EngineValue::Array(values.iter().map(|x| EngineValue::Number(*x)).collect())
112}
113
114fn d(pairs: Vec<(&str, EngineValue)>) -> EngineValue {
115    EngineValue::Dict(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
116}
117
118// ===========================================================================
119// Lookup tables (mirror `antialias` / `justification` arrays)
120// ===========================================================================
121
122const ANTIALIAS: [AntiAlias; 5] = [
123    AntiAlias::None,   // 0
124    AntiAlias::Crisp,  // 1
125    AntiAlias::Strong, // 2
126    AntiAlias::Smooth, // 3
127    AntiAlias::Sharp,  // 4
128];
129
130fn antialias_index(value: AntiAlias) -> i32 {
131    // mirror `antialias.indexOf(...)`; not-found is -1.
132    ANTIALIAS.iter().position(|&a| a == value).map(|i| i as i32).unwrap_or(-1)
133}
134
135fn antialias_at(index: f64) -> AntiAlias {
136    let i = index as i64;
137    if i >= 0 && (i as usize) < ANTIALIAS.len() {
138        ANTIALIAS[i as usize]
139    } else {
140        AntiAlias::Smooth // `?? 'smooth'`
141    }
142}
143
144const JUSTIFICATION: [Justification; 7] = [
145    Justification::Left,          // 0
146    Justification::Right,         // 1
147    Justification::Center,        // 2
148    Justification::JustifyLeft,   // 3
149    Justification::JustifyRight,  // 4
150    Justification::JustifyCenter, // 5
151    Justification::JustifyAll,    // 6
152];
153
154fn justification_index(value: Justification) -> f64 {
155    JUSTIFICATION
156        .iter()
157        .position(|&j| j == value)
158        .map(|i| i as f64)
159        .unwrap_or(-1.0)
160}
161
162fn justification_at(index: f64) -> Option<Justification> {
163    let i = index as i64;
164    if i >= 0 && (i as usize) < JUSTIFICATION.len() {
165        Some(JUSTIFICATION[i as usize])
166    } else {
167        None
168    }
169}
170
171// ===========================================================================
172// Default sheets (mirror defaultFont / defaultParagraphStyle / defaultStyle /
173// defaultGridInfo). Values reproduced exactly.
174// ===========================================================================
175
176fn default_font() -> Font {
177    Font {
178        name: "MyriadPro-Regular".to_string(),
179        script: Some(0.0),
180        font_type: Some(0.0),
181        synthetic: Some(0.0),
182    }
183}
184
185fn default_paragraph_style() -> ParagraphStyle {
186    ParagraphStyle {
187        justification: Some(Justification::Left),
188        first_line_indent: Some(0.0),
189        start_indent: Some(0.0),
190        end_indent: Some(0.0),
191        space_before: Some(0.0),
192        space_after: Some(0.0),
193        auto_hyphenate: Some(true),
194        hyphenated_word_size: Some(6.0),
195        pre_hyphen: Some(2.0),
196        post_hyphen: Some(2.0),
197        consecutive_hyphens: Some(8.0),
198        zone: Some(36.0),
199        word_spacing: Some(vec![0.8, 1.0, 1.33]),
200        letter_spacing: Some(vec![0.0, 0.0, 0.0]),
201        glyph_spacing: Some(vec![1.0, 1.0, 1.0]),
202        auto_leading: Some(1.2),
203        leading_type: Some(0.0),
204        hanging: Some(false),
205        burasagari: Some(false),
206        kinsoku_order: Some(0.0),
207        every_line_composer: Some(false),
208    }
209}
210
211fn default_style() -> TextStyle {
212    TextStyle {
213        font: Some(default_font()),
214        font_size: Some(12.0),
215        faux_bold: Some(false),
216        faux_italic: Some(false),
217        auto_leading: Some(true),
218        leading: Some(0.0),
219        horizontal_scale: Some(1.0),
220        vertical_scale: Some(1.0),
221        tracking: Some(0.0),
222        auto_kerning: Some(true),
223        kerning: Some(0.0),
224        baseline_shift: Some(0.0),
225        font_caps: Some(0.0),
226        font_baseline: Some(0.0),
227        underline: Some(false),
228        strikethrough: Some(false),
229        ligatures: Some(true),
230        d_ligatures: Some(false),
231        baseline_direction: Some(2.0),
232        tsume: Some(0.0),
233        style_run_alignment: Some(2.0),
234        language: Some(0.0),
235        no_break: Some(false),
236        fill_color: Some(Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 0.0 })),
237        stroke_color: Some(Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 0.0 })),
238        fill_flag: Some(true),
239        stroke_flag: Some(false),
240        fill_first: Some(true),
241        y_underline: Some(1.0),
242        outline_width: Some(1.0),
243        character_direction: Some(0.0),
244        hindi_numbers: Some(false),
245        kashida: Some(1.0),
246        diacritic_pos: Some(2.0),
247    }
248}
249
250fn default_grid_info() -> TextGridInfo {
251    TextGridInfo {
252        is_on: Some(false),
253        show: Some(false),
254        size: Some(18.0),
255        leading: Some(22.0),
256        color: Some(Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 255.0 })),
257        leading_fill_color: Some(Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 255.0 })),
258        align_line_height_to_grid_flags: Some(false),
259    }
260}
261
262// ===========================================================================
263// Color encode / decode (mirror decodeColor / encodeColor)
264// ===========================================================================
265
266/// Mirror `decodeColor`. `color` is the `TypeValues` dict `{ Type, Values }`.
267fn decode_color(color: &EngineValue) -> Result<Color, String> {
268    let ty = get(color, "Type").and_then(as_number).unwrap_or(0.0);
269    let c: Vec<f64> = get(color, "Values").map(num_array).unwrap_or_default();
270    let at = |i: usize| c.get(i).copied().unwrap_or(0.0);
271    match ty as i64 {
272        0 => Ok(Color::Grayscale(Grayscale { k: at(1) * 255.0 })),
273        1 => {
274            if at(0) == 1.0 {
275                Ok(Color::Rgb(Rgb {
276                    r: at(1) * 255.0,
277                    g: at(2) * 255.0,
278                    b: at(3) * 255.0,
279                }))
280            } else {
281                Ok(Color::Rgba(Rgba {
282                    r: at(1) * 255.0,
283                    g: at(2) * 255.0,
284                    b: at(3) * 255.0,
285                    a: at(0) * 255.0,
286                }))
287            }
288        }
289        2 => Ok(Color::Cmyk(Cmyk {
290            c: at(1) * 255.0,
291            m: at(2) * 255.0,
292            y: at(3) * 255.0,
293            k: at(4) * 255.0,
294        })),
295        _ => Err("Unknown color type in text layer".to_string()),
296    }
297}
298
299/// Mirror `encodeColor`. Returns the `TypeValues` dict `{ Type, Values }`.
300fn encode_color(color: Option<&Color>) -> EngineValue {
301    match color {
302        None => d(vec![("Type", n(1.0)), ("Values", num_arr(&[0.0, 0.0, 0.0, 0.0]))]),
303        Some(Color::Rgb(c)) => d(vec![
304            ("Type", n(1.0)),
305            ("Values", num_arr(&[1.0, c.r / 255.0, c.g / 255.0, c.b / 255.0])),
306        ]),
307        Some(Color::Rgba(c)) => d(vec![
308            ("Type", n(1.0)),
309            ("Values", num_arr(&[c.a / 255.0, c.r / 255.0, c.g / 255.0, c.b / 255.0])),
310        ]),
311        Some(Color::Cmyk(c)) => d(vec![
312            ("Type", n(2.0)),
313            ("Values", num_arr(&[1.0, c.c / 255.0, c.m / 255.0, c.y / 255.0, c.k / 255.0])),
314        ]),
315        Some(Color::Grayscale(c)) => {
316            d(vec![("Type", n(0.0)), ("Values", num_arr(&[1.0, c.k / 255.0]))])
317        }
318        // upstream throws 'Invalid color type in text layer' for unsupported
319        // color kinds (FRGB/HSB/LAB don't appear in text layers). Mirror as the
320        // default empty RGB rather than panicking in a library.
321        Some(_) => d(vec![("Type", n(1.0)), ("Values", num_arr(&[0.0, 0.0, 0.0, 0.0]))]),
322    }
323}
324
325// ===========================================================================
326// Font dedup / indexing (mirror findOrAddFont)
327// ===========================================================================
328
329fn find_or_add_font(fonts: &mut Vec<Font>, font: &Font) -> f64 {
330    for (i, f) in fonts.iter().enumerate() {
331        if f.name == font.name {
332            return i as f64;
333        }
334    }
335    fonts.push(font.clone());
336    (fonts.len() - 1) as f64
337}
338
339// ===========================================================================
340// Style encode / decode
341//
342// upstream uses a generic `decodeObject`/`encodeObject` keyed by the field name
343// list + upperFirst. With typed Rust structs we map each field explicitly,
344// preserving the SAME key order as `styleKeys` / `paragraphStyleKeys`.
345// ===========================================================================
346
347fn decode_paragraph_style(obj: &EngineValue) -> ParagraphStyle {
348    let mut s = ParagraphStyle::default();
349    if let Some(v) = get(obj, "Justification").and_then(as_number) {
350        s.justification = justification_at(v);
351    }
352    s.first_line_indent = get(obj, "FirstLineIndent").and_then(as_number);
353    s.start_indent = get(obj, "StartIndent").and_then(as_number);
354    s.end_indent = get(obj, "EndIndent").and_then(as_number);
355    s.space_before = get(obj, "SpaceBefore").and_then(as_number);
356    s.space_after = get(obj, "SpaceAfter").and_then(as_number);
357    s.auto_hyphenate = get(obj, "AutoHyphenate").and_then(as_bool);
358    s.hyphenated_word_size = get(obj, "HyphenatedWordSize").and_then(as_number);
359    s.pre_hyphen = get(obj, "PreHyphen").and_then(as_number);
360    s.post_hyphen = get(obj, "PostHyphen").and_then(as_number);
361    s.consecutive_hyphens = get(obj, "ConsecutiveHyphens").and_then(as_number);
362    s.zone = get(obj, "Zone").and_then(as_number);
363    s.word_spacing = get(obj, "WordSpacing").map(num_array);
364    s.letter_spacing = get(obj, "LetterSpacing").map(num_array);
365    s.glyph_spacing = get(obj, "GlyphSpacing").map(num_array);
366    s.auto_leading = get(obj, "AutoLeading").and_then(as_number);
367    s.leading_type = get(obj, "LeadingType").and_then(as_number);
368    s.hanging = get(obj, "Hanging").and_then(as_bool);
369    s.burasagari = get(obj, "Burasagari").and_then(as_bool);
370    s.kinsoku_order = get(obj, "KinsokuOrder").and_then(as_number);
371    s.every_line_composer = get(obj, "EveryLineComposer").and_then(as_bool);
372    s
373}
374
375/// Mirror `decodeStyle`. `fonts` indexes the FontSet so `Font` references resolve.
376fn decode_style(obj: &EngineValue, fonts: &[Font]) -> TextStyle {
377    let mut s = TextStyle::default();
378    if let Some(v) = get(obj, "Font").and_then(as_number) {
379        let idx = v as i64;
380        if idx >= 0 && (idx as usize) < fonts.len() {
381            s.font = Some(fonts[idx as usize].clone());
382        }
383    }
384    s.font_size = get(obj, "FontSize").and_then(as_number);
385    s.faux_bold = get(obj, "FauxBold").and_then(as_bool);
386    s.faux_italic = get(obj, "FauxItalic").and_then(as_bool);
387    s.auto_leading = get(obj, "AutoLeading").and_then(as_bool);
388    s.leading = get(obj, "Leading").and_then(as_number);
389    s.horizontal_scale = get(obj, "HorizontalScale").and_then(as_number);
390    s.vertical_scale = get(obj, "VerticalScale").and_then(as_number);
391    s.tracking = get(obj, "Tracking").and_then(as_number);
392    s.auto_kerning = get(obj, "AutoKerning").and_then(as_bool);
393    s.kerning = get(obj, "Kerning").and_then(as_number);
394    s.baseline_shift = get(obj, "BaselineShift").and_then(as_number);
395    s.font_caps = get(obj, "FontCaps").and_then(as_number);
396    s.font_baseline = get(obj, "FontBaseline").and_then(as_number);
397    s.underline = get(obj, "Underline").and_then(as_bool);
398    s.strikethrough = get(obj, "Strikethrough").and_then(as_bool);
399    s.ligatures = get(obj, "Ligatures").and_then(as_bool);
400    s.d_ligatures = get(obj, "DLigatures").and_then(as_bool);
401    s.baseline_direction = get(obj, "BaselineDirection").and_then(as_number);
402    s.tsume = get(obj, "Tsume").and_then(as_number);
403    s.style_run_alignment = get(obj, "StyleRunAlignment").and_then(as_number);
404    s.language = get(obj, "Language").and_then(as_number);
405    s.no_break = get(obj, "NoBreak").and_then(as_bool);
406    if let Some(c) = get(obj, "FillColor") {
407        s.fill_color = decode_color(c).ok();
408    }
409    if let Some(c) = get(obj, "StrokeColor") {
410        s.stroke_color = decode_color(c).ok();
411    }
412    s.fill_flag = get(obj, "FillFlag").and_then(as_bool);
413    s.stroke_flag = get(obj, "StrokeFlag").and_then(as_bool);
414    s.fill_first = get(obj, "FillFirst").and_then(as_bool);
415    s.y_underline = get(obj, "YUnderline").and_then(as_number);
416    s.outline_width = get(obj, "OutlineWidth").and_then(as_number);
417    s.character_direction = get(obj, "CharacterDirection").and_then(as_number);
418    s.hindi_numbers = get(obj, "HindiNumbers").and_then(as_bool);
419    s.kashida = get(obj, "Kashida").and_then(as_number);
420    s.diacritic_pos = get(obj, "DiacriticPos").and_then(as_number);
421    s
422}
423
424/// Mirror `encodeParagraphStyle`. Emits keys in `paragraphStyleKeys` order,
425/// skipping fields that are `None` (mirror `obj[key] === undefined`).
426fn encode_paragraph_style(s: &ParagraphStyle) -> EngineValue {
427    let mut out: Vec<(String, EngineValue)> = Vec::new();
428    macro_rules! put {
429        ($key:expr, $opt:expr, $f:expr) => {
430            if let Some(v) = $opt {
431                out.push(($key.to_string(), $f(v)));
432            }
433        };
434    }
435    // 'justification'
436    if let Some(j) = s.justification {
437        out.push(("Justification".to_string(), n(justification_index(j))));
438    }
439    put!("FirstLineIndent", s.first_line_indent, n);
440    put!("StartIndent", s.start_indent, n);
441    put!("EndIndent", s.end_indent, n);
442    put!("SpaceBefore", s.space_before, n);
443    put!("SpaceAfter", s.space_after, n);
444    put!("AutoHyphenate", s.auto_hyphenate, b);
445    put!("HyphenatedWordSize", s.hyphenated_word_size, n);
446    put!("PreHyphen", s.pre_hyphen, n);
447    put!("PostHyphen", s.post_hyphen, n);
448    put!("ConsecutiveHyphens", s.consecutive_hyphens, n);
449    put!("Zone", s.zone, n);
450    put!("WordSpacing", s.word_spacing.as_ref(), |v: &Vec<f64>| num_arr(v));
451    put!("LetterSpacing", s.letter_spacing.as_ref(), |v: &Vec<f64>| num_arr(v));
452    put!("GlyphSpacing", s.glyph_spacing.as_ref(), |v: &Vec<f64>| num_arr(v));
453    put!("AutoLeading", s.auto_leading, n);
454    put!("LeadingType", s.leading_type, n);
455    put!("Hanging", s.hanging, b);
456    put!("Burasagari", s.burasagari, b);
457    put!("KinsokuOrder", s.kinsoku_order, n);
458    put!("EveryLineComposer", s.every_line_composer, b);
459    EngineValue::Dict(out)
460}
461
462/// Mirror `encodeStyle`. Emits keys in `styleKeys` order, skipping `None`.
463fn encode_style(s: &TextStyle, fonts: &mut Vec<Font>) -> EngineValue {
464    let mut out: Vec<(String, EngineValue)> = Vec::new();
465    macro_rules! put {
466        ($key:expr, $opt:expr, $f:expr) => {
467            if let Some(v) = $opt {
468                out.push(($key.to_string(), $f(v)));
469            }
470        };
471    }
472    // 'font'
473    if let Some(f) = s.font.as_ref() {
474        out.push(("Font".to_string(), n(find_or_add_font(fonts, f))));
475    }
476    put!("FontSize", s.font_size, n);
477    put!("FauxBold", s.faux_bold, b);
478    put!("FauxItalic", s.faux_italic, b);
479    put!("AutoLeading", s.auto_leading, b);
480    put!("Leading", s.leading, n);
481    put!("HorizontalScale", s.horizontal_scale, n);
482    put!("VerticalScale", s.vertical_scale, n);
483    put!("Tracking", s.tracking, n);
484    put!("AutoKerning", s.auto_kerning, b);
485    put!("Kerning", s.kerning, n);
486    put!("BaselineShift", s.baseline_shift, n);
487    put!("FontCaps", s.font_caps, n);
488    put!("FontBaseline", s.font_baseline, n);
489    put!("Underline", s.underline, b);
490    put!("Strikethrough", s.strikethrough, b);
491    put!("Ligatures", s.ligatures, b);
492    put!("DLigatures", s.d_ligatures, b);
493    put!("BaselineDirection", s.baseline_direction, n);
494    put!("Tsume", s.tsume, n);
495    put!("StyleRunAlignment", s.style_run_alignment, n);
496    put!("Language", s.language, n);
497    put!("NoBreak", s.no_break, b);
498    // 'fillColor'
499    if let Some(c) = s.fill_color.as_ref() {
500        out.push(("FillColor".to_string(), encode_color(Some(c))));
501    }
502    if let Some(c) = s.stroke_color.as_ref() {
503        out.push(("StrokeColor".to_string(), encode_color(Some(c))));
504    }
505    put!("FillFlag", s.fill_flag, b);
506    put!("StrokeFlag", s.stroke_flag, b);
507    put!("FillFirst", s.fill_first, b);
508    put!("YUnderline", s.y_underline, n);
509    put!("OutlineWidth", s.outline_width, n);
510    put!("CharacterDirection", s.character_direction, n);
511    put!("HindiNumbers", s.hindi_numbers, b);
512    put!("Kashida", s.kashida, n);
513    put!("DiacriticPos", s.diacritic_pos, n);
514    EngineValue::Dict(out)
515}
516
517// ===========================================================================
518// Merge helpers (mirror the `{ ...defaults, ...overrides }` spreads)
519// ===========================================================================
520
521fn merge_paragraph(base: &ParagraphStyle, over: &ParagraphStyle) -> ParagraphStyle {
522    let mut r = base.clone();
523    macro_rules! m {
524        ($field:ident) => {
525            if over.$field.is_some() {
526                r.$field = over.$field.clone();
527            }
528        };
529    }
530    m!(justification);
531    m!(first_line_indent);
532    m!(start_indent);
533    m!(end_indent);
534    m!(space_before);
535    m!(space_after);
536    m!(auto_hyphenate);
537    m!(hyphenated_word_size);
538    m!(pre_hyphen);
539    m!(post_hyphen);
540    m!(consecutive_hyphens);
541    m!(zone);
542    m!(word_spacing);
543    m!(letter_spacing);
544    m!(glyph_spacing);
545    m!(auto_leading);
546    m!(leading_type);
547    m!(hanging);
548    m!(burasagari);
549    m!(kinsoku_order);
550    m!(every_line_composer);
551    r
552}
553
554fn merge_style(base: &TextStyle, over: &TextStyle) -> TextStyle {
555    let mut r = base.clone();
556    macro_rules! m {
557        ($field:ident) => {
558            if over.$field.is_some() {
559                r.$field = over.$field.clone();
560            }
561        };
562    }
563    m!(font);
564    m!(font_size);
565    m!(faux_bold);
566    m!(faux_italic);
567    m!(auto_leading);
568    m!(leading);
569    m!(horizontal_scale);
570    m!(vertical_scale);
571    m!(tracking);
572    m!(auto_kerning);
573    m!(kerning);
574    m!(baseline_shift);
575    m!(font_caps);
576    m!(font_baseline);
577    m!(underline);
578    m!(strikethrough);
579    m!(ligatures);
580    m!(d_ligatures);
581    m!(baseline_direction);
582    m!(tsume);
583    m!(style_run_alignment);
584    m!(language);
585    m!(no_break);
586    m!(fill_color);
587    m!(stroke_color);
588    m!(fill_flag);
589    m!(stroke_flag);
590    m!(fill_first);
591    m!(y_underline);
592    m!(outline_width);
593    m!(character_direction);
594    m!(hindi_numbers);
595    m!(kashida);
596    m!(diacritic_pos);
597    r
598}
599
600// ===========================================================================
601// decodeEngineData
602// ===========================================================================
603
604/// Port of `decodeEngineData`. Converts a parsed EngineData `EngineValue` into a
605/// friendly `LayerTextData`.
606pub fn decode_engine_data(engine_data: &EngineValue) -> LayerTextData {
607    let engine_dict = get(engine_data, "EngineDict").cloned().unwrap_or(EngineValue::Null);
608    let resource_dict = get(engine_data, "ResourceDict").cloned().unwrap_or(EngineValue::Null);
609
610    // fonts
611    let fonts: Vec<Font> = get(&resource_dict, "FontSet")
612        .and_then(as_array)
613        .map(|arr| {
614            arr.iter()
615                .map(|f| Font {
616                    name: get(f, "Name").and_then(as_str).unwrap_or("").to_string(),
617                    script: get(f, "Script").and_then(as_number),
618                    font_type: get(f, "FontType").and_then(as_number),
619                    synthetic: get(f, "Synthetic").and_then(as_number),
620                })
621                .collect()
622        })
623        .unwrap_or_default();
624
625    // text: Editor.Text with \r -> \n, then trim trailing \n counting removals.
626    let raw_text = get(&engine_dict, "Editor")
627        .and_then(|e| get(e, "Text"))
628        .and_then(as_str)
629        .unwrap_or("")
630        .to_string();
631    // operate on UTF-16 units to mirror JS .length/charCodeAt semantics
632    let mut units: Vec<u16> = raw_text.encode_utf16().map(|u| if u == 13 { 10 } else { u }).collect();
633    let mut removed_characters: usize = 0;
634    while units.last() == Some(&10) {
635        units.pop();
636        removed_characters += 1;
637    }
638    let text = String::from_utf16_lossy(&units);
639
640    let mut result = LayerTextData {
641        text,
642        anti_alias: Some(antialias_at(
643            get(&engine_dict, "AntiAlias").and_then(as_number).unwrap_or(0.0),
644        )),
645        use_fractional_glyph_widths: Some(truthy_bool(get(&engine_dict, "UseFractionalGlyphWidths"))),
646        superscript_size: get(&resource_dict, "SuperscriptSize").and_then(as_number),
647        superscript_position: get(&resource_dict, "SuperscriptPosition").and_then(as_number),
648        subscript_size: get(&resource_dict, "SubscriptSize").and_then(as_number),
649        subscript_position: get(&resource_dict, "SubscriptPosition").and_then(as_number),
650        small_cap_size: get(&resource_dict, "SmallCapSize").and_then(as_number),
651        ..Default::default()
652    };
653
654    // shape
655    let photoshop = get(&engine_dict, "Rendered")
656        .and_then(|r| get(r, "Shapes"))
657        .and_then(|s| get(s, "Children"))
658        .and_then(as_array)
659        .and_then(|c| c.first())
660        .and_then(|c0| get(c0, "Cookie"))
661        .and_then(|ck| get(ck, "Photoshop"))
662        .cloned();
663
664    if let Some(ps) = photoshop {
665        let shape_type = get(&ps, "ShapeType").and_then(as_number).unwrap_or(0.0);
666        result.shape_type = Some(if shape_type == 1.0 {
667            TextShapeType::Box
668        } else {
669            TextShapeType::Point
670        });
671        if let Some(pb) = get(&ps, "PointBase") {
672            result.point_base = Some(num_array(pb));
673        }
674        if let Some(bb) = get(&ps, "BoxBounds") {
675            result.box_bounds = Some(num_array(bb));
676        }
677    }
678
679    // paragraph style
680    let paragraph_run = get(&engine_dict, "ParagraphRun").cloned().unwrap_or(EngineValue::Null);
681    result.paragraph_style = Some(ParagraphStyle::default());
682    let mut paragraph_style_runs: Vec<ParagraphStyleRun> = Vec::new();
683
684    let p_run_array = get(&paragraph_run, "RunArray").and_then(as_array).map(|a| a.to_vec()).unwrap_or_default();
685    let p_len_array: Vec<f64> = get(&paragraph_run, "RunLengthArray").map(num_array).unwrap_or_default();
686    for (i, run) in p_run_array.iter().enumerate() {
687        let length = p_len_array.get(i).copied().unwrap_or(0.0);
688        let props = get(run, "ParagraphSheet")
689            .and_then(|sheet| get(sheet, "Properties"))
690            .cloned()
691            .unwrap_or(EngineValue::Dict(Vec::new()));
692        let style = decode_paragraph_style(&props);
693        paragraph_style_runs.push(ParagraphStyleRun { length, style });
694    }
695
696    // trim removed trailing characters off the last run(s)
697    let mut counter = removed_characters;
698    while !paragraph_style_runs.is_empty() && counter > 0 {
699        let last = paragraph_style_runs.len() - 1;
700        paragraph_style_runs[last].length -= 1.0;
701        if paragraph_style_runs[last].length == 0.0 {
702            paragraph_style_runs.pop();
703        }
704        counter -= 1;
705    }
706
707    deduplicate_paragraph(result.paragraph_style.as_mut().unwrap(), &mut paragraph_style_runs);
708    result.paragraph_style_runs = if paragraph_style_runs.is_empty() {
709        None
710    } else {
711        Some(paragraph_style_runs)
712    };
713
714    // style
715    let style_run = get(&engine_dict, "StyleRun").cloned().unwrap_or(EngineValue::Null);
716    result.style = Some(TextStyle::default());
717    let mut style_runs: Vec<TextStyleRun> = Vec::new();
718
719    let s_run_array = get(&style_run, "RunArray").and_then(as_array).map(|a| a.to_vec()).unwrap_or_default();
720    let s_len_array: Vec<f64> = get(&style_run, "RunLengthArray").map(num_array).unwrap_or_default();
721    for (i, run) in s_run_array.iter().enumerate() {
722        let length = s_len_array.get(i).copied().unwrap_or(0.0);
723        let data = get(run, "StyleSheet")
724            .and_then(|ss| get(ss, "StyleSheetData"))
725            .cloned()
726            .unwrap_or(EngineValue::Dict(Vec::new()));
727        let mut style = decode_style(&data, &fonts);
728        if style.font.is_none() {
729            style.font = fonts.first().cloned();
730        }
731        style_runs.push(TextStyleRun { length, style });
732    }
733
734    let mut counter = removed_characters;
735    while !style_runs.is_empty() && counter > 0 {
736        let last = style_runs.len() - 1;
737        style_runs[last].length -= 1.0;
738        if style_runs[last].length == 0.0 {
739            style_runs.pop();
740        }
741        counter -= 1;
742    }
743
744    deduplicate_style(result.style.as_mut().unwrap(), &mut style_runs);
745    result.style_runs = if style_runs.is_empty() { None } else { Some(style_runs) };
746
747    result
748}
749
750// ===========================================================================
751// deduplicateValues — specialized per style kind.
752//
753// upstream: for each key, if all runs share run[0]'s value, copy it to `base`
754// and delete it from each run that matches `base`. If every run ends up empty,
755// clear the run list. We mirror exactly, per-field.
756// ===========================================================================
757
758macro_rules! dedup_field {
759    ($base:expr, $runs:expr, $field:ident) => {{
760        if let Some(first) = $runs.first() {
761            let value = first.style.$field.clone();
762            if value.is_some() {
763                let identical = $runs.iter().all(|r| r.style.$field == value);
764                if identical {
765                    $base.$field = value.clone();
766                }
767            }
768            if $base.$field.is_some() {
769                let bv = $base.$field.clone();
770                for r in $runs.iter_mut() {
771                    if r.style.$field == bv {
772                        r.style.$field = None;
773                    }
774                }
775            }
776        }
777    }};
778}
779
780fn paragraph_style_is_empty(s: &ParagraphStyle) -> bool {
781    s.justification.is_none()
782        && s.first_line_indent.is_none()
783        && s.start_indent.is_none()
784        && s.end_indent.is_none()
785        && s.space_before.is_none()
786        && s.space_after.is_none()
787        && s.auto_hyphenate.is_none()
788        && s.hyphenated_word_size.is_none()
789        && s.pre_hyphen.is_none()
790        && s.post_hyphen.is_none()
791        && s.consecutive_hyphens.is_none()
792        && s.zone.is_none()
793        && s.word_spacing.is_none()
794        && s.letter_spacing.is_none()
795        && s.glyph_spacing.is_none()
796        && s.auto_leading.is_none()
797        && s.leading_type.is_none()
798        && s.hanging.is_none()
799        && s.burasagari.is_none()
800        && s.kinsoku_order.is_none()
801        && s.every_line_composer.is_none()
802}
803
804fn deduplicate_paragraph(base: &mut ParagraphStyle, runs: &mut Vec<ParagraphStyleRun>) {
805    if runs.is_empty() {
806        return;
807    }
808    dedup_field!(base, runs, justification);
809    dedup_field!(base, runs, first_line_indent);
810    dedup_field!(base, runs, start_indent);
811    dedup_field!(base, runs, end_indent);
812    dedup_field!(base, runs, space_before);
813    dedup_field!(base, runs, space_after);
814    dedup_field!(base, runs, auto_hyphenate);
815    dedup_field!(base, runs, hyphenated_word_size);
816    dedup_field!(base, runs, pre_hyphen);
817    dedup_field!(base, runs, post_hyphen);
818    dedup_field!(base, runs, consecutive_hyphens);
819    dedup_field!(base, runs, zone);
820    dedup_field!(base, runs, word_spacing);
821    dedup_field!(base, runs, letter_spacing);
822    dedup_field!(base, runs, glyph_spacing);
823    dedup_field!(base, runs, auto_leading);
824    dedup_field!(base, runs, leading_type);
825    dedup_field!(base, runs, hanging);
826    dedup_field!(base, runs, burasagari);
827    dedup_field!(base, runs, kinsoku_order);
828    dedup_field!(base, runs, every_line_composer);
829
830    if runs.iter().all(|r| paragraph_style_is_empty(&r.style)) {
831        runs.clear();
832    }
833}
834
835fn style_is_empty(s: &TextStyle) -> bool {
836    s.font.is_none()
837        && s.font_size.is_none()
838        && s.faux_bold.is_none()
839        && s.faux_italic.is_none()
840        && s.auto_leading.is_none()
841        && s.leading.is_none()
842        && s.horizontal_scale.is_none()
843        && s.vertical_scale.is_none()
844        && s.tracking.is_none()
845        && s.auto_kerning.is_none()
846        && s.kerning.is_none()
847        && s.baseline_shift.is_none()
848        && s.font_caps.is_none()
849        && s.font_baseline.is_none()
850        && s.underline.is_none()
851        && s.strikethrough.is_none()
852        && s.ligatures.is_none()
853        && s.d_ligatures.is_none()
854        && s.baseline_direction.is_none()
855        && s.tsume.is_none()
856        && s.style_run_alignment.is_none()
857        && s.language.is_none()
858        && s.no_break.is_none()
859        && s.fill_color.is_none()
860        && s.stroke_color.is_none()
861        && s.fill_flag.is_none()
862        && s.stroke_flag.is_none()
863        && s.fill_first.is_none()
864        && s.y_underline.is_none()
865        && s.outline_width.is_none()
866        && s.character_direction.is_none()
867        && s.hindi_numbers.is_none()
868        && s.kashida.is_none()
869        && s.diacritic_pos.is_none()
870}
871
872fn deduplicate_style(base: &mut TextStyle, runs: &mut Vec<TextStyleRun>) {
873    if runs.is_empty() {
874        return;
875    }
876    // `font` compares by structural equality; Font isn't PartialEq, so compare by name+fields.
877    {
878        if let Some(first) = runs.first() {
879            let value = first.style.font.clone();
880            if value.is_some() {
881                let identical = runs.iter().all(|r| font_eq(&r.style.font, &value));
882                if identical {
883                    base.font = value.clone();
884                }
885            }
886            if base.font.is_some() {
887                let bv = base.font.clone();
888                for r in runs.iter_mut() {
889                    if font_eq(&r.style.font, &bv) {
890                        r.style.font = None;
891                    }
892                }
893            }
894        }
895    }
896    dedup_field!(base, runs, font_size);
897    dedup_field!(base, runs, faux_bold);
898    dedup_field!(base, runs, faux_italic);
899    dedup_field!(base, runs, auto_leading);
900    dedup_field!(base, runs, leading);
901    dedup_field!(base, runs, horizontal_scale);
902    dedup_field!(base, runs, vertical_scale);
903    dedup_field!(base, runs, tracking);
904    dedup_field!(base, runs, auto_kerning);
905    dedup_field!(base, runs, kerning);
906    dedup_field!(base, runs, baseline_shift);
907    dedup_field!(base, runs, font_caps);
908    dedup_field!(base, runs, font_baseline);
909    dedup_field!(base, runs, underline);
910    dedup_field!(base, runs, strikethrough);
911    dedup_field!(base, runs, ligatures);
912    dedup_field!(base, runs, d_ligatures);
913    dedup_field!(base, runs, baseline_direction);
914    dedup_field!(base, runs, tsume);
915    dedup_field!(base, runs, style_run_alignment);
916    dedup_field!(base, runs, language);
917    dedup_field!(base, runs, no_break);
918    dedup_field!(base, runs, fill_color);
919    dedup_field!(base, runs, stroke_color);
920    dedup_field!(base, runs, fill_flag);
921    dedup_field!(base, runs, stroke_flag);
922    dedup_field!(base, runs, fill_first);
923    dedup_field!(base, runs, y_underline);
924    dedup_field!(base, runs, outline_width);
925    dedup_field!(base, runs, character_direction);
926    dedup_field!(base, runs, hindi_numbers);
927    dedup_field!(base, runs, kashida);
928    dedup_field!(base, runs, diacritic_pos);
929
930    if runs.iter().all(|r| style_is_empty(&r.style)) {
931        runs.clear();
932    }
933}
934
935fn font_eq(a: &Option<Font>, b: &Option<Font>) -> bool {
936    match (a, b) {
937        (Some(x), Some(y)) => {
938            x.name == y.name
939                && x.script == y.script
940                && x.font_type == y.font_type
941                && x.synthetic == y.synthetic
942        }
943        (None, None) => true,
944        _ => false,
945    }
946}
947
948// ===========================================================================
949// encodeEngineData
950// ===========================================================================
951
952/// Port of `encodeEngineData`. Builds the EngineData `EngineValue` from friendly
953/// `LayerTextData`. Key order matches the TS object-literals exactly.
954pub fn encode_engine_data(data: &LayerTextData) -> EngineValue {
955    // text = `${(text||'').replace(/\r?\n/g, '\r')}\r`
956    let text = normalize_text(&data.text);
957    let text_len = text.encode_utf16().count() as f64;
958    let text_units: Vec<u16> = text.encode_utf16().collect();
959
960    // fonts list starts with AdobeInvisFont
961    let mut fonts: Vec<Font> = vec![Font {
962        name: "AdobeInvisFont".to_string(),
963        script: Some(0.0),
964        font_type: Some(0.0),
965        synthetic: Some(0.0),
966    }];
967
968    // def font: data.style.font || first styleRun with font || defaultFont
969    let def_font = data
970        .style
971        .as_ref()
972        .and_then(|s| s.font.clone())
973        .or_else(|| {
974            data.style_runs
975                .as_ref()
976                .and_then(|runs| runs.iter().find_map(|r| r.style.font.clone()))
977        })
978        .unwrap_or_else(default_font);
979
980    let data_paragraph_style = data.paragraph_style.clone().unwrap_or_default();
981    let default_para = default_paragraph_style();
982
983    // ---- paragraph runs ----
984    let mut paragraph_run_array: Vec<EngineValue> = Vec::new();
985    let mut paragraph_run_length_array: Vec<f64> = Vec::new();
986
987    let para_sheet = |props: EngineValue| {
988        d(vec![
989            ("DefaultStyleSheet", n(0.0)),
990            ("Properties", props),
991        ])
992    };
993    let para_run_entry = |props: EngineValue| {
994        d(vec![
995            ("ParagraphSheet", para_sheet(props)),
996            (
997                "Adjustments",
998                d(vec![("Axis", num_arr(&[1.0, 0.0, 1.0])), ("XY", num_arr(&[0.0, 0.0]))]),
999            ),
1000        ])
1001    };
1002
1003    let has_para_runs = data
1004        .paragraph_style_runs
1005        .as_ref()
1006        .map(|r| !r.is_empty())
1007        .unwrap_or(false);
1008
1009    if has_para_runs {
1010        let runs = data.paragraph_style_runs.as_ref().unwrap();
1011        let mut left_length = text_len;
1012        let last_idx = runs.len() - 1;
1013
1014        for (i, run) in runs.iter().enumerate() {
1015            let mut run_length = run.length.min(left_length);
1016            left_length -= run_length;
1017
1018            if run_length == 0.0 {
1019                continue;
1020            }
1021
1022            // extend last run if it's only for trailing \r
1023            if left_length == 1.0 && i == last_idx {
1024                run_length += 1.0;
1025                left_length -= 1.0;
1026            }
1027
1028            paragraph_run_length_array.push(run_length);
1029            let merged = merge_paragraph(&merge_paragraph(&default_para, &data_paragraph_style), &run.style);
1030            paragraph_run_array.push(para_run_entry(encode_paragraph_style(&merged)));
1031        }
1032
1033        if left_length != 0.0 {
1034            paragraph_run_length_array.push(left_length);
1035            let merged = merge_paragraph(&default_para, &data_paragraph_style);
1036            paragraph_run_array.push(para_run_entry(encode_paragraph_style(&merged)));
1037        }
1038    } else {
1039        let mut last = 0usize;
1040        for i in 0..text_units.len() {
1041            if text_units[i] == 13 {
1042                // \r
1043                paragraph_run_length_array.push((i - last + 1) as f64);
1044                let merged = merge_paragraph(&default_para, &data_paragraph_style);
1045                paragraph_run_array.push(para_run_entry(encode_paragraph_style(&merged)));
1046                last = i + 1;
1047            }
1048        }
1049    }
1050
1051    // ---- style sheet + style runs ----
1052    let mut style_sheet_base = default_style();
1053    style_sheet_base.font = Some(def_font.clone());
1054    let style_sheet_data = encode_style(&style_sheet_base, &mut fonts);
1055
1056    let data_style = data.style.clone().unwrap_or_default();
1057
1058    // styleRuns default = [{ length: text.length, style: data.style || {} }]
1059    let style_runs: Vec<TextStyleRun> = match data.style_runs.as_ref() {
1060        Some(r) => r.clone(),
1061        None => vec![TextStyleRun {
1062            length: text_len,
1063            style: data_style.clone(),
1064        }],
1065    };
1066
1067    let mut style_run_array: Vec<EngineValue> = Vec::new();
1068    let mut style_run_length_array: Vec<f64> = Vec::new();
1069
1070    // base for each run: { kerning:0, autoKerning:true, fillColor:{0,0,0}, ...data.style, ...run.style }
1071    let run_base = || {
1072        let mut s = TextStyle {
1073            kerning: Some(0.0),
1074            auto_kerning: Some(true),
1075            fill_color: Some(Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 0.0 })),
1076            ..Default::default()
1077        };
1078        s = merge_style(&s, &data_style);
1079        s
1080    };
1081
1082    let mut left_length = text_len;
1083    let last_idx = if style_runs.is_empty() { 0 } else { style_runs.len() - 1 };
1084
1085    for (i, run) in style_runs.iter().enumerate() {
1086        let mut run_length = run.length.min(left_length);
1087        left_length -= run_length;
1088
1089        if run_length == 0.0 {
1090            continue;
1091        }
1092
1093        if left_length == 1.0 && i == last_idx {
1094            run_length += 1.0;
1095            left_length -= 1.0;
1096        }
1097
1098        style_run_length_array.push(run_length);
1099        let merged = merge_style(&run_base(), &run.style);
1100        style_run_array.push(d(vec![(
1101            "StyleSheet",
1102            d(vec![("StyleSheetData", encode_style(&merged, &mut fonts))]),
1103        )]));
1104    }
1105
1106    // add extra run to the end if existing ones didn't fill it up
1107    if left_length != 0.0 && !style_runs.is_empty() {
1108        style_run_length_array.push(left_length);
1109        let merged = run_base();
1110        style_run_array.push(d(vec![(
1111            "StyleSheet",
1112            d(vec![("StyleSheetData", encode_style(&merged, &mut fonts))]),
1113        )]));
1114    }
1115
1116    // ---- grid info / shape ----
1117    let grid_info = merge_grid(&default_grid_info(), data.grid_info.as_ref());
1118    let writing_direction = if data.orientation == Some(Orientation::Vertical) { 2.0 } else { 0.0 };
1119    let procession = if data.orientation == Some(Orientation::Vertical) { 1.0 } else { 0.0 };
1120    let shape_type = if data.shape_type == Some(TextShapeType::Box) { 1.0 } else { 0.0 };
1121
1122    // Photoshop node, properties in exact order: ShapeType, (PointBase|BoxBounds), Base
1123    let mut photoshop_pairs: Vec<(&str, EngineValue)> = vec![("ShapeType", n(shape_type))];
1124    if shape_type == 0.0 {
1125        let pb = data.point_base.clone().unwrap_or_else(|| vec![0.0, 0.0]);
1126        photoshop_pairs.push(("PointBase", num_arr(&pb)));
1127    } else {
1128        let bb = data.box_bounds.clone().unwrap_or_else(|| vec![0.0, 0.0, 0.0, 0.0]);
1129        photoshop_pairs.push(("BoxBounds", num_arr(&bb)));
1130    }
1131    photoshop_pairs.push((
1132        "Base",
1133        d(vec![
1134            ("ShapeType", n(shape_type)),
1135            ("TransformPoint0", num_arr(&[1.0, 0.0])),
1136            ("TransformPoint1", num_arr(&[0.0, 1.0])),
1137            ("TransformPoint2", num_arr(&[0.0, 0.0])),
1138        ]),
1139    ));
1140    let photoshop = d(photoshop_pairs);
1141
1142    // ---- default resources ----
1143    let build_resources = || -> EngineValue {
1144        let kinsoku_set = EngineValue::Array(vec![
1145            d(vec![
1146                ("Name", EngineValue::Str("PhotoshopKinsokuHard".to_string())),
1147                ("NoStart", EngineValue::Str("、。,.・:;?!ー―’”)〕]}〉》」』】ヽヾゝゞ々ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ゛゜?!)]},.:;℃℉¢%‰".to_string())),
1148                ("NoEnd", EngineValue::Str("‘“(〔[{〈《「『【([{¥$£@§〒#".to_string())),
1149                ("Keep", EngineValue::Str("―‥".to_string())),
1150                ("Hanging", EngineValue::Str("、。.,".to_string())),
1151            ]),
1152            d(vec![
1153                ("Name", EngineValue::Str("PhotoshopKinsokuSoft".to_string())),
1154                ("NoStart", EngineValue::Str("、。,.・:;?!’”)〕]}〉》」』】ヽヾゝゞ々".to_string())),
1155                ("NoEnd", EngineValue::Str("‘“(〔[{〈《「『【".to_string())),
1156                ("Keep", EngineValue::Str("―‥".to_string())),
1157                ("Hanging", EngineValue::Str("、。.,".to_string())),
1158            ]),
1159        ]);
1160        let mojikumi_set = EngineValue::Array(vec![
1161            d(vec![("InternalName", EngineValue::Str("Photoshop6MojiKumiSet1".to_string()))]),
1162            d(vec![("InternalName", EngineValue::Str("Photoshop6MojiKumiSet2".to_string()))]),
1163            d(vec![("InternalName", EngineValue::Str("Photoshop6MojiKumiSet3".to_string()))]),
1164            d(vec![("InternalName", EngineValue::Str("Photoshop6MojiKumiSet4".to_string()))]),
1165        ]);
1166
1167        let paragraph_sheet_set = EngineValue::Array(vec![d(vec![
1168            ("Name", EngineValue::Str("Normal RGB".to_string())),
1169            ("DefaultStyleSheet", n(0.0)),
1170            (
1171                "Properties",
1172                encode_paragraph_style(&merge_paragraph(&default_para, &data_paragraph_style)),
1173            ),
1174        ])]);
1175
1176        let style_sheet_set = EngineValue::Array(vec![d(vec![
1177            ("Name", EngineValue::Str("Normal RGB".to_string())),
1178            ("StyleSheetData", style_sheet_data.clone()),
1179        ])]);
1180
1181        let font_set = EngineValue::Array(
1182            fonts
1183                .iter()
1184                .map(|f| {
1185                    d(vec![
1186                        ("Name", EngineValue::Str(f.name.clone())),
1187                        ("Script", n(f.script.unwrap_or(0.0))),
1188                        ("FontType", n(f.font_type.unwrap_or(0.0))),
1189                        ("Synthetic", n(f.synthetic.unwrap_or(0.0))),
1190                    ])
1191                })
1192                .collect(),
1193        );
1194
1195        d(vec![
1196            ("KinsokuSet", kinsoku_set),
1197            ("MojiKumiSet", mojikumi_set),
1198            ("TheNormalStyleSheet", n(0.0)),
1199            ("TheNormalParagraphSheet", n(0.0)),
1200            ("ParagraphSheetSet", paragraph_sheet_set),
1201            ("StyleSheetSet", style_sheet_set),
1202            ("FontSet", font_set),
1203            ("SuperscriptSize", n(data.superscript_size.unwrap_or(0.583))),
1204            ("SuperscriptPosition", n(data.superscript_position.unwrap_or(0.333))),
1205            ("SubscriptSize", n(data.subscript_size.unwrap_or(0.583))),
1206            ("SubscriptPosition", n(data.subscript_position.unwrap_or(0.333))),
1207            ("SmallCapSize", n(data.small_cap_size.unwrap_or(0.7))),
1208        ])
1209    };
1210
1211    let resource_dict = build_resources();
1212    let document_resources = build_resources();
1213
1214    // ---- engine dict ----
1215    let engine_dict = d(vec![
1216        ("Editor", d(vec![("Text", EngineValue::Str(text.clone()))])),
1217        (
1218            "ParagraphRun",
1219            d(vec![
1220                (
1221                    "DefaultRunData",
1222                    d(vec![
1223                        (
1224                            "ParagraphSheet",
1225                            d(vec![
1226                                ("DefaultStyleSheet", n(0.0)),
1227                                ("Properties", EngineValue::Dict(Vec::new())),
1228                            ]),
1229                        ),
1230                        (
1231                            "Adjustments",
1232                            d(vec![("Axis", num_arr(&[1.0, 0.0, 1.0])), ("XY", num_arr(&[0.0, 0.0]))]),
1233                        ),
1234                    ]),
1235                ),
1236                ("RunArray", EngineValue::Array(paragraph_run_array)),
1237                ("RunLengthArray", num_arr(&paragraph_run_length_array)),
1238                ("IsJoinable", n(1.0)),
1239            ]),
1240        ),
1241        (
1242            "StyleRun",
1243            d(vec![
1244                (
1245                    "DefaultRunData",
1246                    d(vec![(
1247                        "StyleSheet",
1248                        d(vec![("StyleSheetData", EngineValue::Dict(Vec::new()))]),
1249                    )]),
1250                ),
1251                ("RunArray", EngineValue::Array(style_run_array)),
1252                ("RunLengthArray", num_arr(&style_run_length_array)),
1253                ("IsJoinable", n(2.0)),
1254            ]),
1255        ),
1256        (
1257            "GridInfo",
1258            d(vec![
1259                ("GridIsOn", b(grid_info.is_on.unwrap_or(false))),
1260                ("ShowGrid", b(grid_info.show.unwrap_or(false))),
1261                ("GridSize", n(grid_info.size.unwrap_or(18.0))),
1262                ("GridLeading", n(grid_info.leading.unwrap_or(22.0))),
1263                ("GridColor", encode_color(grid_info.color.as_ref())),
1264                ("GridLeadingFillColor", encode_color(grid_info.color.as_ref())),
1265                ("AlignLineHeightToGridFlags", b(grid_info.align_line_height_to_grid_flags.unwrap_or(false))),
1266            ]),
1267        ),
1268        (
1269            "AntiAlias",
1270            n(antialias_index(data.anti_alias.unwrap_or(AntiAlias::Sharp)) as f64),
1271        ),
1272        (
1273            "UseFractionalGlyphWidths",
1274            b(data.use_fractional_glyph_widths.unwrap_or(true)),
1275        ),
1276        (
1277            "Rendered",
1278            d(vec![
1279                ("Version", n(1.0)),
1280                (
1281                    "Shapes",
1282                    d(vec![
1283                        ("WritingDirection", n(writing_direction)),
1284                        (
1285                            "Children",
1286                            EngineValue::Array(vec![d(vec![
1287                                ("ShapeType", n(shape_type)),
1288                                ("Procession", n(procession)),
1289                                (
1290                                    "Lines",
1291                                    d(vec![
1292                                        ("WritingDirection", n(writing_direction)),
1293                                        ("Children", EngineValue::Array(Vec::new())),
1294                                    ]),
1295                                ),
1296                                ("Cookie", d(vec![("Photoshop", photoshop)])),
1297                            ])]),
1298                        ),
1299                    ]),
1300                ),
1301            ]),
1302        ),
1303    ]);
1304
1305    d(vec![
1306        ("EngineDict", engine_dict),
1307        ("ResourceDict", resource_dict),
1308        ("DocumentResources", document_resources),
1309    ])
1310}
1311
1312/// `(text || '').replace(/\r?\n/g, '\r') + '\r'`
1313fn normalize_text(text: &str) -> String {
1314    // replace \r\n and \n with \r, leave standalone \r as-is
1315    let mut out = String::with_capacity(text.len() + 1);
1316    let bytes: Vec<char> = text.chars().collect();
1317    let mut i = 0;
1318    while i < bytes.len() {
1319        let c = bytes[i];
1320        if c == '\r' {
1321            if i + 1 < bytes.len() && bytes[i + 1] == '\n' {
1322                // \r\n -> \r
1323                out.push('\r');
1324                i += 2;
1325            } else {
1326                // standalone \r — regex /\r?\n/ would not match it (no \n), kept as-is
1327                out.push('\r');
1328                i += 1;
1329            }
1330        } else if c == '\n' {
1331            out.push('\r');
1332            i += 1;
1333        } else {
1334            out.push(c);
1335            i += 1;
1336        }
1337    }
1338    out.push('\r');
1339    out
1340}
1341
1342fn merge_grid(base: &TextGridInfo, over: Option<&TextGridInfo>) -> TextGridInfo {
1343    let mut r = base.clone();
1344    if let Some(o) = over {
1345        if o.is_on.is_some() {
1346            r.is_on = o.is_on;
1347        }
1348        if o.show.is_some() {
1349            r.show = o.show;
1350        }
1351        if o.size.is_some() {
1352            r.size = o.size;
1353        }
1354        if o.leading.is_some() {
1355            r.leading = o.leading;
1356        }
1357        if o.color.is_some() {
1358            r.color = o.color;
1359        }
1360        if o.leading_fill_color.is_some() {
1361            r.leading_fill_color = o.leading_fill_color;
1362        }
1363        if o.align_line_height_to_grid_flags.is_some() {
1364            r.align_line_height_to_grid_flags = o.align_line_height_to_grid_flags;
1365        }
1366    }
1367    r
1368}
1369
1370// ===========================================================================
1371// Tests
1372// ===========================================================================
1373
1374#[cfg(test)]
1375mod tests {
1376    use super::*;
1377    use crate::engine_data::{parse_engine_data, serialize_engine_data};
1378
1379    fn keys_of(v: &EngineValue) -> Vec<String> {
1380        match v {
1381            EngineValue::Dict(m) => m.iter().map(|(k, _)| k.clone()).collect(),
1382            _ => vec![],
1383        }
1384    }
1385
1386    #[test]
1387    fn round_trip_two_style_runs() {
1388        let data = LayerTextData {
1389            text: "Hello\nWorld".to_string(),
1390            anti_alias: Some(AntiAlias::Smooth),
1391            orientation: Some(Orientation::Horizontal),
1392            paragraph_style: Some(ParagraphStyle {
1393                justification: Some(Justification::Center),
1394                ..Default::default()
1395            }),
1396            style_runs: Some(vec![
1397                TextStyleRun {
1398                    length: 5.0,
1399                    style: TextStyle {
1400                        font: Some(Font {
1401                            name: "Arial".to_string(),
1402                            script: Some(0.0),
1403                            font_type: Some(0.0),
1404                            synthetic: Some(0.0),
1405                        }),
1406                        font_size: Some(24.0),
1407                        fill_color: Some(Color::Rgb(Rgb { r: 255.0, g: 0.0, b: 0.0 })),
1408                        ..Default::default()
1409                    },
1410                },
1411                TextStyleRun {
1412                    length: 6.0,
1413                    style: TextStyle {
1414                        font: Some(Font {
1415                            name: "Arial".to_string(),
1416                            script: Some(0.0),
1417                            font_type: Some(0.0),
1418                            synthetic: Some(0.0),
1419                        }),
1420                        font_size: Some(24.0),
1421                        fill_color: Some(Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 255.0 })),
1422                        ..Default::default()
1423                    },
1424                },
1425            ]),
1426            ..Default::default()
1427        };
1428
1429        let encoded = encode_engine_data(&data);
1430        // serialize then parse to ensure bytes survive the round trip
1431        let bytes = serialize_engine_data(&encoded, false);
1432        let parsed = parse_engine_data(&bytes).unwrap();
1433        assert_eq!(parsed, encoded, "serialize/parse round-trip must be stable");
1434
1435        let decoded = decode_engine_data(&parsed);
1436
1437        // text: \n preserved (was \r in EngineData, decoded back to \n)
1438        assert_eq!(decoded.text, "Hello\nWorld");
1439        assert_eq!(decoded.anti_alias, Some(AntiAlias::Smooth));
1440
1441        // style runs survived
1442        let runs = decoded.style_runs.expect("style runs present");
1443        assert_eq!(runs.len(), 2);
1444        // lengths: 5 and 6 (trailing \r folded into the last run during encode,
1445        // then trimmed back on decode)
1446        assert_eq!(runs[0].length, 5.0);
1447        assert_eq!(runs[1].length, 6.0);
1448
1449        // distinguishing field: fill color differs between runs, so it stays per-run
1450        assert_eq!(
1451            runs[0].style.fill_color,
1452            Some(Color::Rgb(Rgb { r: 255.0, g: 0.0, b: 0.0 }))
1453        );
1454        assert_eq!(
1455            runs[1].style.fill_color,
1456            Some(Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 255.0 }))
1457        );
1458
1459        // shared font dedups into base style
1460        assert_eq!(decoded.style.as_ref().unwrap().font.as_ref().unwrap().name, "Arial");
1461        assert_eq!(decoded.style.as_ref().unwrap().font_size, Some(24.0));
1462
1463        // paragraph: justification center round-trips
1464        let pruns = decoded.paragraph_style_runs.as_ref();
1465        let just = decoded
1466            .paragraph_style
1467            .as_ref()
1468            .and_then(|p| p.justification)
1469            .or_else(|| pruns.and_then(|r| r.first()).and_then(|r| r.style.justification));
1470        assert_eq!(just, Some(Justification::Center));
1471    }
1472
1473    #[test]
1474    fn default_sheets_key_sets() {
1475        // empty data exercises the default sheets path
1476        let data = LayerTextData {
1477            text: "x".to_string(),
1478            ..Default::default()
1479        };
1480        let encoded = encode_engine_data(&data);
1481
1482        // top-level keys
1483        assert_eq!(
1484            keys_of(&encoded),
1485            vec!["EngineDict", "ResourceDict", "DocumentResources"]
1486        );
1487
1488        // ResourceDict key set + order
1489        let rd = get(&encoded, "ResourceDict").unwrap();
1490        assert_eq!(
1491            keys_of(rd),
1492            vec![
1493                "KinsokuSet",
1494                "MojiKumiSet",
1495                "TheNormalStyleSheet",
1496                "TheNormalParagraphSheet",
1497                "ParagraphSheetSet",
1498                "StyleSheetSet",
1499                "FontSet",
1500                "SuperscriptSize",
1501                "SuperscriptPosition",
1502                "SubscriptSize",
1503                "SubscriptPosition",
1504                "SmallCapSize",
1505            ]
1506        );
1507
1508        // EngineDict key set + order
1509        let ed = get(&encoded, "EngineDict").unwrap();
1510        assert_eq!(
1511            keys_of(ed),
1512            vec![
1513                "Editor",
1514                "ParagraphRun",
1515                "StyleRun",
1516                "GridInfo",
1517                "AntiAlias",
1518                "UseFractionalGlyphWidths",
1519                "Rendered",
1520            ]
1521        );
1522
1523        // default paragraph sheet Properties must carry all 21 default keys in order
1524        let props = get(rd, "ParagraphSheetSet")
1525            .and_then(as_array)
1526            .and_then(|a| a.first())
1527            .and_then(|s| get(s, "Properties"))
1528            .unwrap();
1529        assert_eq!(
1530            keys_of(props),
1531            vec![
1532                "Justification",
1533                "FirstLineIndent",
1534                "StartIndent",
1535                "EndIndent",
1536                "SpaceBefore",
1537                "SpaceAfter",
1538                "AutoHyphenate",
1539                "HyphenatedWordSize",
1540                "PreHyphen",
1541                "PostHyphen",
1542                "ConsecutiveHyphens",
1543                "Zone",
1544                "WordSpacing",
1545                "LetterSpacing",
1546                "GlyphSpacing",
1547                "AutoLeading",
1548                "LeadingType",
1549                "Hanging",
1550                "Burasagari",
1551                "KinsokuOrder",
1552                "EveryLineComposer",
1553            ]
1554        );
1555
1556        // default style sheet data: first key is Font, includes FillColor/StrokeColor
1557        let ssd = get(rd, "StyleSheetSet")
1558            .and_then(as_array)
1559            .and_then(|a| a.first())
1560            .and_then(|s| get(s, "StyleSheetData"))
1561            .unwrap();
1562        let ssd_keys = keys_of(ssd);
1563        assert_eq!(ssd_keys.first().map(|s| s.as_str()), Some("Font"));
1564        assert!(ssd_keys.contains(&"FillColor".to_string()));
1565        assert!(ssd_keys.contains(&"DiacriticPos".to_string()));
1566
1567        // FontSet: AdobeInvisFont first, then the default MyriadPro-Regular
1568        let font_set = get(rd, "FontSet").and_then(as_array).unwrap();
1569        assert_eq!(
1570            get(&font_set[0], "Name").and_then(as_str),
1571            Some("AdobeInvisFont")
1572        );
1573        assert_eq!(
1574            get(&font_set[1], "Name").and_then(as_str),
1575            Some("MyriadPro-Regular")
1576        );
1577    }
1578
1579    #[test]
1580    fn color_round_trip() {
1581        // rgb
1582        let c = encode_color(Some(&Color::Rgb(Rgb { r: 255.0, g: 128.0, b: 0.0 })));
1583        match decode_color(&c).unwrap() {
1584            Color::Rgb(rgb) => {
1585                assert!((rgb.r - 255.0).abs() < 1e-6);
1586                assert!((rgb.g - 128.0).abs() < 1e-6);
1587                assert!((rgb.b - 0.0).abs() < 1e-6);
1588            }
1589            _ => panic!("expected rgb"),
1590        }
1591        // grayscale
1592        let c = encode_color(Some(&Color::Grayscale(Grayscale { k: 200.0 })));
1593        match decode_color(&c).unwrap() {
1594            Color::Grayscale(g) => assert!((g.k - 200.0).abs() < 1e-6),
1595            _ => panic!("expected grayscale"),
1596        }
1597        // none -> default
1598        let c = encode_color(None);
1599        assert_eq!(get(&c, "Type").and_then(as_number), Some(1.0));
1600    }
1601
1602    #[test]
1603    fn paragraph_runs_from_newlines() {
1604        // No paragraph_style_runs supplied: encode splits on \r boundaries.
1605        let data = LayerTextData {
1606            text: "a\nb\nc".to_string(),
1607            ..Default::default()
1608        };
1609        let encoded = encode_engine_data(&data);
1610        let pr = get(&encoded, "EngineDict").and_then(|e| get(e, "ParagraphRun")).unwrap();
1611        let lens = get(pr, "RunLengthArray").map(num_array).unwrap();
1612        // text becomes "a\rb\rc\r" (len 6): runs end at each \r -> [2,2,2]
1613        assert_eq!(lens, vec![2.0, 2.0, 2.0]);
1614    }
1615}