M16: keep grapheme clusters intact in vertical watermarks
This commit is contained in:
parent
32cedb3153
commit
93b900f8d7
|
|
@ -2899,6 +2899,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -258,33 +258,41 @@ fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::F
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// The font's vertical form, positioned from its vertical origin within the watermark cell.
|
||||
/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright.
|
||||
/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too.
|
||||
fn vertical_watermark_glyph(
|
||||
fn watermark_cell_glyphs(
|
||||
font: &ab_glyph::FontRef<'_>,
|
||||
data: &harfrust::ShaperData,
|
||||
ch: char,
|
||||
text: &str,
|
||||
vertical: bool,
|
||||
px: f32,
|
||||
left: f32,
|
||||
top: f32,
|
||||
) -> Option<ab_glyph::Glyph> {
|
||||
) -> Option<Vec<ab_glyph::Glyph>> {
|
||||
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::<f32>();
|
||||
let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::<f32>();
|
||||
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<dyn Iterator<Item = &str>> =
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue