diff --git a/Cargo.lock b/Cargo.lock index b1f42080..86d4bca6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2899,6 +2899,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "unicode-segmentation", "web-time", ] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index d419eaea..a916d180 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -32,6 +32,7 @@ log = { workspace = true } ab_glyph = { workspace = true } # Apply the font’s vertical substitutions and origins to Japanese watermarks. harfrust = { version = "0.12", default-features = false, features = ["std"] } +unicode-segmentation = "1.13" lightcraft-segment = { workspace = true, optional = true } [features] diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 218baa30..33366285 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -258,33 +258,41 @@ fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec, data: &harfrust::ShaperData, - ch: char, + text: &str, + vertical: bool, px: f32, left: f32, top: f32, -) -> Option { +) -> Option> { use ab_glyph::{Font, ScaleFont}; let face = harfrust::FontRef::new(font.font_data()).ok()?; let mut buf = harfrust::UnicodeBuffer::new(); - buf.add(ch, 0); - buf.set_direction(harfrust::Direction::TopToBottom); + buf.push_str(text); + buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight }); buf.guess_segment_properties(); let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new()); - if shaped.glyph_infos().len() != 1 { + if shaped.glyph_infos().is_empty() { return None; } - let gid = u16::try_from(shaped.glyph_infos().first()?.glyph_id).ok().filter(|g| *g != 0)?; - let pos = shaped.glyph_positions().first()?; let sf = font.as_scaled(px); let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor()); - let height = -(pos.y_advance as f32) * sy; - let pen = ab_glyph::point(left + px / 2.0 + pos.x_offset as f32 * sx, top + (px - height) / 2.0 - pos.y_offset as f32 * sy); - Some(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen)) + let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::(); + let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::(); + let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) }; + let mut glyphs = Vec::new(); + for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { + let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?; + let pen = ab_glyph::point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy); + glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen)); + x += pos.x_advance as f32 * sx; + y -= pos.y_advance as f32 * sy; + } + Some(glyphs) } /// Draw `wm` onto `img` (straight alpha blending of the encoded values). @@ -355,8 +363,13 @@ fn watermark_coverage( let mut prev = None; let columns = text.split('\n').count(); let mut column = 0usize; - for original in text.chars() { - if original == '\n' { + use unicode_segmentation::UnicodeSegmentation; + // Horizontal watermarks retain scalar layout and kerning; only vertical cells use graphemes. + let cells: Box> = + if wm.vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) }; + for cell in cells { + let Some(original) = cell.chars().next() else { continue }; + if cell == "\n" || (wm.vertical && cell == "\r\n") { if wm.vertical { column = column.saturating_add(1); tw = tw.max(y); @@ -369,17 +382,20 @@ fn watermark_coverage( prev = None; continue; } - if wm.vertical - && matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60) - && let Some(face) = fonts.iter().position(|font| font.glyph_id(original).0 != 0) - && let (Some(font), Some(Some(data))) = (fonts.get(face), vertical_data.get(face)) - { + let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60); + if wm.vertical && (cjk || cell.chars().count() > 1) { let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; - if let Some(glyph) = vertical_watermark_glyph(font, data, original, px, left, y) - && (glyph.id != font.glyph_id(original) - || !matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】')) - { - glyphs.push((face, glyph, None)); + let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| { + let data = data.as_ref()?; + let cluster = watermark_cell_glyphs(font, data, cell, cjk, px, left, y)?; + let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original)); + if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') { + return None; + } + Some((face, cluster)) + }); + if let Some((face, cluster)) = shaped { + glyphs.extend(cluster.into_iter().map(|g| (face, g, None))); y += px; continue; } @@ -1706,7 +1722,7 @@ mod tests { assert_eq!(font.glyph_id('︒').0, 0); let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap()); for ch in ['、', '。'] { - let glyph = vertical_watermark_glyph(&font, &data, ch, 100.0, 0.0, 0.0).unwrap(); + let glyph = watermark_cell_glyphs(&font, &data, &ch.to_string(), true, 100.0, 0.0, 0.0).unwrap().remove(0); assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}"); let rect = font.outline_glyph(glyph).unwrap().px_bounds(); assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}"); @@ -1736,6 +1752,56 @@ mod tests { } } + #[test] + fn vertical_watermarks_keep_combining_marks_in_one_cell() { + let coverage = |text: &str, craft| { + let wm = Watermark { + text: text.into(), + vertical: true, + size: 0.1, + anchor: Anchor::Center, + inset: 0.0, + shadow: false, + opacity: 1.0, + ..Default::default() + }; + let mut pixels = vec![0.0f32; 400 * 300]; + watermark_coverage(400, 300, &wm, craft, |x, y, k, _| pixels[y * 400 + x] = k); + assert!(pixels.iter().any(|&k| k > 0.0)); + pixels + }; + let compare = |a: &str, b: &str, craft| { + let (a_pixels, b_pixels) = (coverage(a, craft), coverage(b, craft)); + let difference = a_pixels.iter().zip(&b_pixels).position(|(a, b)| a != b); + assert!(difference.is_none(), "{a:?} vs {b:?}: first differing pixel {difference:?}"); + }; + for (composed, decomposed) in [("éA", "e\u{301}A"), ("Å\nA", "A\u{30a}\nA"), ("A\nB", "A\r\nB"), ("日A", "日\u{e0100}A")] { + compare(composed, decomposed, &[]); + } + if crate::fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDMincho") { + for (composed, decomposed) in [("が日", "か\u{3099}日"), ("ぱ\n日", "は\u{309a}\n日")] { + compare(composed, decomposed, crate::fonts::CRAFT_FONTS); + } + } else { + eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts"); + } + } + + #[test] + fn upright_watermark_cell_retains_multiple_glyphs() { + use ab_glyph::{Font, ScaleFont}; + let font = ab_glyph::FontRef::try_from_slice(WATERMARK_FONT).unwrap(); + let data = harfrust::ShaperData::new(&harfrust::FontRef::new(WATERMARK_FONT).unwrap()); + // There is no precomposed A with a combining long solidus overlay. + let glyphs = watermark_cell_glyphs(&font, &data, "A\u{338}", false, 30.0, 0.0, 0.0).unwrap(); + assert_eq!(glyphs.len(), 2, "retain the base and the separately positioned combining glyph"); + let baseline = font.as_scaled(30.0).ascent(); + for glyph in glyphs { + assert!((glyph.position.y - baseline).abs() < 1.0, "no extra vertical cell for the mark"); + assert!(font.outline_glyph(glyph).is_some(), "both glyphs have ink"); + } + } + #[test] fn japanese_watermarks_support_vertical_columns() { use ab_glyph::Font; diff --git a/docs/parity.md b/docs/parity.md index a503d4ae..9cc31824 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -445,7 +445,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EXP-HDR | HDR output | P2 | ⬜ | | | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | -| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor`, `inset` (fraction of the short edge, 0..0.4), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183) | +| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor`, `inset` (fraction of the short edge, 0..0.4), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183) | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | diff --git a/docs/vertical-watermarks.md b/docs/vertical-watermarks.md new file mode 100644 index 00000000..bd10516c --- /dev/null +++ b/docs/vertical-watermarks.md @@ -0,0 +1,20 @@ +# Vertical text watermarks + +Set `watermark.vertical` to `true` in export options, or choose Vertical in the export dialog. Text runs down each column; a newline starts the next column to its left. The size, nine anchors, inset, opacity, colour and shadow use the same controls as horizontal watermarks. Existing presets without `vertical` remain horizontal. + +## Character cells + +A vertical cell contains one extended grapheme cluster, not one Unicode scalar. For example, `が` and `か` followed by U+3099 COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK occupy the same space and produce the same image with the Japanese fonts from craft-fonts. The source string is not normalized or rewritten. CRLF and LF each start one new column. + +The renderer shapes the complete cluster, retaining all resulting glyphs and their offsets, even when a base and its combining mark remain separate glyphs. It tries the existing watermark faces in order and rejects a shaped cluster containing a missing glyph. Without a suitable font, the existing fallback draws the first character's missing-glyph box in one cell rather than allocating cells to its marks or variation selectors. + +Japanese cells use the font's vertical substitutions and origins. Latin text remains upright, as in the existing watermark renderer; clusters with Latin combining marks are shaped horizontally within their cell. This is not a general mixed-script paragraph composer. It does not add rotated Latin runs, tate-chu-yoko, ruby, kinsoku, automatic column wrapping or mojikumi spacing. + +## References and scope + +- [Unicode UAX #29, §3 Grapheme Cluster Boundaries](https://www.unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries) specifically identifies vertical-text segmentation as a use for grapheme clusters and states that boundaries must not split combining sequences. This is the basis for the cell fix. +- [OpenType `vert`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_uz#tag-vert) describes upright vertical forms and their script sensitivity. The renderer uses harfrust's top-to-bottom shaping for Japanese cells, retaining the previously implemented font-origin placement. +- [W3C Requirements for Japanese Text Layout](https://www.w3.org/TR/jlreq/) describes Japanese paragraph composition, including punctuation spacing and prohibited line boundaries. Those rules are relevant to a future paragraph composer; this fix does not claim full JLReq conformance. +- [Lightroom Classic's public watermark workflow](https://helpx.adobe.com/lightroom-classic/help/using-watermark-editor.html) documents text/graphic watermarks, opacity, size, inset and nine anchors. It does not document Japanese vertical composition. LightCraft's vertical cells are an extension, not a claim to match an undocumented Lightroom vertical-text mode. + +Regression coverage in `crates/engine/src/export.rs` compares actual coverage pixels for composed and decomposed Japanese and Latin text, checks multi-glyph mark placement, and retains the existing punctuation and right-to-left-column tests. Japanese font-specific comparisons run with `CRAFT_FONTS_DIR`; Latin and missing-font comparisons run without it.