diff --git a/docs/docs/api/style.md b/docs/docs/api/style.md index 159d67b..46fd00f 100644 --- a/docs/docs/api/style.md +++ b/docs/docs/api/style.md @@ -43,6 +43,12 @@ typedef struct tile57_mariner { double sounding_size_scale; /* extra size multiplier for SOUNDINGS, on top of * size_scale (scales each digit + spacing together). * 1.0 = none; 0 reads as 1.0. */ + uint8_t soundings; /* spot soundings, independent of the display + * category. 0 = follow the category, 1 = always + * show, 2 = always hide. S-52 files SOUNDG under + * OTHER, so 0 means a host enables the whole OTHER + * category to get soundings, and takes the seabed + * and the cables with it. */ double device_scale; /* device px per reference px — the HiDPI density the * SURFACE paths are drawn at (2.0 on a Retina backing * store). Describes the DISPLAY where size_scale @@ -52,6 +58,22 @@ typedef struct tile57_mariner { * it (that density is already in the requested * width/height). 1.0 = a 1x framebuffer; 0 reads as * 1.0. */ + bool chart_over_image; /* CHART OVER PICTURE: a raster chart is drawn + * beneath this one, so the DEPARE / DRGARE / UNSARE + * / LNDARE fills and the no-data background drop + * out and the picture shows through. Contours, + * symbols, lights, soundings, text and boundaries + * already drawn as lines or patterns stay. Engages + * the S-52 §10.3.4.2 DisplayPlane precedence, which + * the clause conditions on an image beneath the + * chart; radar_overlay satisfies the same gate. */ + char preferred_language[4]; /* the mariner's label language, an ISO 639-2 code + * such as "zho", or "" for the portrayed name. A code + * the chart states draws that language's name; any + * other code still draws an S-57 national name + * (NOBJNM), which records no language. The bake + * stores each language beside the portrayed name, so + * this switches without a re-bake. */ } tile57_mariner; void tile57_mariner_defaults(tile57_mariner *m); /* canonical defaults, date_view = "" */ diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 9a253c1..9837522 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -51,6 +51,12 @@ result is **best effort**: - **Missing S-101 content stays missing.** S-101 attributes and features with no S-57 source are never invented; rules that test them take their fallback branch. +- **A national name has no language.** S-57 stores one `NOBJNM` per feature and + records only the lexical level of the text (part 3 clause 2.4), which is a + character repertoire rather than a language. The adapter converts it to a + second `featureName` tagged ISO 639-2 `und`, undetermined, so the + mariner's language setting reaches it whatever code they ask for. S-101 permits several `featureName` entries with distinct + languages. An S-57 source yields at most one. - **Unconvertible objects fall back or drop.** An S-57 object class with no S-101 equivalent portrays as the S-52 question mark (QUESMRK1) — or, where the S-65 guidance says the object is simply not carried into S-101 (e.g. an @@ -95,6 +101,18 @@ result is **best effort**: ## Display / style gaps +- **The embedded font covers Latin, Greek and Cyrillic.** A label in another + script draws with the bundled Noto Sans, which has no glyphs for it, and comes + out as boxes. Point `TILE57_FONT_FALLBACK` at a TrueType file or collection + holding the script, and the pixel outputs draw each codepoint the bundled face + lacks from that one. The PDF and vector outputs embed a single face per label, + so a label mixing scripts still draws its fallback glyphs from the bundled + face there. A host that supplies its own face through the surface callbacks is + unaffected. +- **A chart states more than four languages.** The bake runs a portrayal pass + per language and stores a text property per language, so it keeps the first + four it finds and drops the rest. + - **Overscale hatch occlusion is tile-path only.** The S-52 §10.1.10 overscale indication (`OVERSC01`, see [architecture](./architecture.md)) is gated correctly everywhere, but only the generated MapLibre style sandwiches the diff --git a/docs/docs/rendering.md b/docs/docs/rendering.md index 84c4894..7b3dd8f 100644 --- a/docs/docs/rendering.md +++ b/docs/docs/rendering.md @@ -119,7 +119,7 @@ tile57 png chart.pmtiles --view -76.48,38.974,15.1 --size 1024x768 -o out.png # Mariner settings tile57 png ... --safety 5 --safety-depth 5 --feet --palette night \ - --no-names --plain --simplified --dq --scale 1.5 + --no-names --language zho --plain --simplified --dq --scale 1.5 ``` ### From C (and therefore Go, Python, C++, …) diff --git a/docs/docs/tile-schema.md b/docs/docs/tile-schema.md index 80f2a57..42e0cba 100644 --- a/docs/docs/tile-schema.md +++ b/docs/docs/tile-schema.md @@ -146,11 +146,12 @@ Depth soundings, drawn as digit glyph strings (SNDFRM digit composition). ### text Text labels (the name typically derives from `OBJNAM` via the `featureName` -attribute). +attribute; a feature named in other languages also gets a `text_` for each). | Field | Type | Meaning | | --- | --- | --- | | `text` | string | The label text. | +| `text_` | string | The same label in one of the languages the chart states, keyed by ISO 639-2 code, one property per language. An S-57 national name (`NOBJNM`) is keyed `text_und`, because S-57 records no language for it. A client selects with the mariner's language without a re-bake. | | `font_size_px` | number | Font size in pixels. | | `color_token` | string | Text color name. | | `halo_color_token` | string | Halo color name (`""` = no halo). | diff --git a/include/tile57.h b/include/tile57.h index 4384037..2ddb575 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -720,6 +720,15 @@ typedef struct tile57_mariner { * specified values and blending them produces colours the * spec does not name. * Appended for ABI-append-safety; a zeroed struct is off. */ + char preferred_language[4]; /* The mariner's label language, an ISO 639-2 + * code such as "zho", or "" for the portrayed name. A + * code the chart states draws that language's name. Any + * other code still draws an S-57 national name (NOBJNM), + * because S-57 records no language for it. + * The bake stores each language the chart states beside + * the portrayed name, so this switches without a + * re-bake. Appended for ABI-append-safety; a zeroed + * struct keeps the portrayed name. */ } tile57_mariner; /* Fill *m with the canonical default mariner settings (so a host needn't diff --git a/src/capi.zig b/src/capi.zig index 1942858..4aee25d 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -2219,6 +2219,14 @@ export fn tile57_assets_free(out: ?*CAssets) callconv(.c) void { // ---- chart-style generation (mirrors tile57_mariner in tile57.h) ----------- +/// The mariner language as the ABI's fixed field. A code longer than the field +/// holds is dropped, which reads as the portrayed name. +fn langCode(s: []const u8) [4]u8 { + var out = [_]u8{0} ** 4; + if (s.len < out.len) @memcpy(out[0..s.len], s); + return out; +} + const CMariner = extern struct { scheme: c_int, shallow_contour: f64, @@ -2280,6 +2288,11 @@ const CMariner = extern struct { // background so a raster chart beneath shows through, and engage the // DisplayPlane precedence. See tile57.h. Appended for ABI-append-safety. chart_over_image: bool, + // The mariner's label language, an ISO 639-2 code, or "" for the portrayed + // name. The bake stores each language the chart states beside that name, so + // this switches without a re-bake. Appended for ABI-append-safety; a zeroed + // struct keeps the portrayed name. + preferred_language: [4]u8, }; /// The tri-state `soundings` field as the engine's optional bool. @@ -2342,6 +2355,7 @@ fn marinerFromC(cm: *const CMariner) mariner.Settings { .sounding_size_scale = if (cm.sounding_size_scale > 0) cm.sounding_size_scale else 1.0, .device_scale = if (cm.device_scale > 0) cm.device_scale else 1.0, .chart_over_image = cm.chart_over_image, + .preferred_language = std.mem.sliceTo(&cm.preferred_language, 0), .viewing_groups_off = if (cm.viewing_groups_off != null and cm.viewing_groups_off_len > 0) cm.viewing_groups_off[0..cm.viewing_groups_off_len] else @@ -2526,6 +2540,7 @@ export fn tile57_mariner_defaults(cm: ?*CMariner) callconv(.c) void { .sounding_size_scale = d.sounding_size_scale, .device_scale = d.device_scale, .chart_over_image = d.chart_over_image, + .preferred_language = langCode(d.preferred_language), }; } diff --git a/src/chart.zig b/src/chart.zig index 42ac17c..8579e44 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -230,6 +230,7 @@ const CellBackend = struct { portrayal_plain: ?[]const ?[]const u8 = null, // PlainBoundaries variant (areas) portrayal_simplified: ?[]const ?[]const u8 = null, // SimplifiedSymbols variant (points) portrayal_lights: ?[]const ?[]const u8 = null, // FullLightLines variant (sectored lights) + portrayal_national: []const scene.LangStreams = &.{}, // one pass per national language portray_arena: ?*std.heap.ArenaAllocator = null, coverage: []const []const []const s57.LonLat = &.{}, // M_COVR (in portray_arena) cscl: i32 = 0, // compilation scale (DSPM CSCL, 1:N) @@ -267,6 +268,7 @@ fn cellRef(cb: *CellBackend) scene.CellRef { .portrayal_plain = cb.portrayal_plain, .portrayal_simplified = cb.portrayal_simplified, .portrayal_lights = cb.portrayal_lights, + .portrayal_national = cb.portrayal_national, .geo = cb.geo, .geo_world = cb.geo_world, .feat_bbox = cb.feat_bbox, @@ -290,6 +292,7 @@ const LazyCell = struct { portrayal_plain: ?[]const ?[]const u8 = null, portrayal_simplified: ?[]const ?[]const u8 = null, portrayal_lights: ?[]const ?[]const u8 = null, + portrayal_national: []const scene.LangStreams = &.{}, arena: ?*std.heap.ArenaAllocator = null, tick: u64 = 0, // LRU: last tile that used this cell // M_COVR(CATCOV=1) coverage polygons, assembled once from `cell` for best-band @@ -587,6 +590,14 @@ fn parseAnyCell(base: []const u8, updates: []const []const u8) ?CellLoad { /// Portray a cell three ways, using the native adapted set (S-101) when present, /// else the S-57 adapter. +/// The portrayal's national passes as the scene's own type. The two modules +/// state the same pair, and scene has no dependency on portray. +fn nationalPasses(a: std.mem.Allocator, passes: []const portray.LangStreams) []const scene.LangStreams { + const out = a.alloc(scene.LangStreams, passes.len) catch return &.{}; + for (passes, out) |p, *o| o.* = .{ .lang = p.lang, .streams = p.streams }; + return out; +} + fn portrayVariantsAny(arena: std.mem.Allocator, cell: *const s57.Cell, adapted: ?[]const s101.adapter.Adapted, dir: []const u8) !portray.CellPortrayal { if (adapted) |ad| return portray.portrayCellVariantsAdapted(arena, cell, ad, dir); return portray.portrayCellVariants(arena, cell, dir); @@ -610,6 +621,7 @@ fn lazyEnsureLoaded(ls: *LazySource, lc: *LazyCell) void { lc.portrayal_plain = cp.plain; lc.portrayal_simplified = cp.simplified; lc.portrayal_lights = cp.lights; + lc.portrayal_national = nationalPasses(p.allocator(), cp.national); lc.arena = p; } else |_| { p.deinit(); @@ -639,6 +651,7 @@ fn lazyUnload(lc: *LazyCell) void { lc.portrayal_plain = null; lc.portrayal_simplified = null; lc.portrayal_lights = null; + lc.portrayal_national = &.{}; if (lc.arena) |p| { p.deinit(); gpa.destroy(p); @@ -780,6 +793,7 @@ fn buildCellBackend(base: []const u8, updates: []const []const u8, dir: []const cb.portrayal_plain = cp.plain; cb.portrayal_simplified = cp.simplified; cb.portrayal_lights = cp.lights; + cb.portrayal_national = nationalPasses(pa.allocator(), cp.national); } else |_| {} // Assemble geometry + its projection + per-feature bboxes ONCE (the baker's // per-cell caches) so live per-view rendering reuses them across the view's tiles @@ -3104,6 +3118,7 @@ pub const Chart = struct { .portrayal_plain = cb.portrayal_plain, .portrayal_simplified = cb.portrayal_simplified, .portrayal_lights = cb.portrayal_lights, + .portrayal_national = cb.portrayal_national, }}; return scene.generateView(&ps, a, gpa, &one, lon, lat, zoom, self.pick_attrs) catch error.TileGen; }, @@ -3366,6 +3381,7 @@ pub const Chart = struct { .portrayal_plain = cb2.portrayal_plain, .portrayal_simplified = cb2.portrayal_simplified, .portrayal_lights = cb2.portrayal_lights, + .portrayal_national = cb2.portrayal_national, .geo = cb2.geo, .geo_world = cb2.geo_world, .feat_bbox = cb2.feat_bbox, @@ -3528,6 +3544,7 @@ pub const Chart = struct { .portrayal_plain = cb2.portrayal_plain, .portrayal_simplified = cb2.portrayal_simplified, .portrayal_lights = cb2.portrayal_lights, + .portrayal_national = cb2.portrayal_national, }}; scene.appendTile(surf, a, &one, z, qt.tx, qt.ty, self.pick_attrs) catch continue; }, @@ -3581,6 +3598,7 @@ pub const Chart = struct { .portrayal_plain = cb.portrayal_plain, .portrayal_simplified = cb.portrayal_simplified, .portrayal_lights = cb.portrayal_lights, + .portrayal_national = cb.portrayal_national, }}; return scene.generateView(&as, a, gpa, &one, lon, lat, zoom, self.pick_attrs) catch error.TileGen; }, @@ -4156,6 +4174,7 @@ const BakeWork = struct { var portrayal_plain: ?[]const ?[]const u8 = null; var portrayal_simplified: ?[]const ?[]const u8 = null; var portrayal_lights: ?[]const ?[]const u8 = null; + var portrayal_national: []const scene.LangStreams = &.{}; var geo: ?scene.GeoParts = null; var geo_world: ?scene.GeoWorld = null; var feat_bbox: ?[]const ?[4]f64 = null; @@ -4167,6 +4186,7 @@ const BakeWork = struct { portrayal_plain = cp.plain; portrayal_simplified = cp.simplified; portrayal_lights = cp.lights; + portrayal_national = nationalPasses(p.allocator(), cp.national); } else |_| {} // Build the geometry cache for EVERY cell, unconditionally. // `build_geo` (cacheGeoForBand) gated it to the finer bands, but coarse cells are @@ -4205,7 +4225,7 @@ const BakeWork = struct { // Sector-figure reach (exact, from the portrayal streams): buildTileMap // addresses the neighbouring tiles the cell's light legs/arcs cross. const lr = scene.collectLightReach(&cell, portrayal); - c.outs[i] = .{ .cell = cell, .portrayal = portrayal, .portrayal_plain = portrayal_plain, .portrayal_simplified = portrayal_simplified, .portrayal_lights = portrayal_lights, .geo = geo, .geo_world = geo_world, .feat_bbox = feat_bbox, .bounds = b, .cscl = cscl, .coverage = coverage, .scamins = scamins, .light_bbox = lr.bbox, .light_range_m = lr.range_m }; + c.outs[i] = .{ .cell = cell, .portrayal = portrayal, .portrayal_plain = portrayal_plain, .portrayal_simplified = portrayal_simplified, .portrayal_lights = portrayal_lights, .portrayal_national = portrayal_national, .geo = geo, .geo_world = geo_world, .feat_bbox = feat_bbox, .bounds = b, .cscl = cscl, .coverage = coverage, .scamins = scamins, .light_bbox = lr.bbox, .light_range_m = lr.range_m }; c.arenas[i] = pa; } }; diff --git a/src/portray/lua_shim.c b/src/portray/lua_shim.c index 97b0128..f8835d0 100644 --- a/src/portray/lua_shim.c +++ b/src/portray/lua_shim.c @@ -140,6 +140,10 @@ typedef struct tg_portray_ctx { double shallow_contour; double deep_contour; double safety_height; + /* ISO 639-2 code the rules match featureName.language against. The + * catalogue's GetFeatureName returns the entry whose language equals this, + * and falls back to the nameUsage 1 entry when none does. */ + const char *preferred_language; } tg_portray_ctx; static const tg_portray_ctx tg_default_ctx = { @@ -166,6 +170,8 @@ static void tg_set_ctx_globals(lua_State *L, const tg_portray_ctx *ctx) { TG_SET_REAL("SHALLOW_CONTOUR", ctx->shallow_contour); TG_SET_REAL("DEEP_CONTOUR", ctx->deep_contour); TG_SET_REAL("SAFETY_HEIGHT", ctx->safety_height); + lua_pushstring(L, ctx->preferred_language ? ctx->preferred_language : "eng"); + lua_setglobal(L, "PREFERRED_LANGUAGE"); #undef TG_SET_BOOL #undef TG_SET_REAL } @@ -583,7 +589,7 @@ int tile57_diag_portray_demo(const char *dir) { "cp('ShallowWaterDangers','boolean','false'); cp('SafetyContour','real','30')\n" "cp('SafetyDepth','real','30'); cp('ShallowContour','real','2')\n" "cp('DeepContour','real','30'); cp('SafetyHeight','real','0')\n" - "cp('PreferredLanguage','text','eng')\n" + "cp('PreferredLanguage','text', PREFERRED_LANGUAGE)\n" "PortrayalInitializeContextParameters(cps)\n" "local out={}\n" "local ctx=portrayalContext.ContextParameters\n" @@ -1107,7 +1113,7 @@ int tg_portray_run(const char *dir, size_t dir_len, const tg_portray_ctx *ctx) { "cp('ShallowWaterDangers','boolean', b(SHALLOW_WATER_DANGERS)); cp('SafetyContour','real', SAFETY_CONTOUR)\n" "cp('SafetyDepth','real', SAFETY_DEPTH); cp('ShallowContour','real', SHALLOW_CONTOUR)\n" "cp('DeepContour','real', DEEP_CONTOUR); cp('SafetyHeight','real', SAFETY_HEIGHT)\n" - "cp('PreferredLanguage','text','eng')\n" + "cp('PreferredLanguage','text', PREFERRED_LANGUAGE)\n" "PortrayalInitializeContextParameters(cps)\n" // Drive portrayal through the reference S-100 Part 9a entry point exactly as // the catalogue intends. HostPortrayalEmit is the framework's per-feature diff --git a/src/portray/portray.zig b/src/portray/portray.zig index 8f1c78b..49631d0 100644 --- a/src/portray/portray.zig +++ b/src/portray/portray.zig @@ -52,6 +52,7 @@ const CContext = extern struct { shallow_contour: f64, deep_contour: f64, safety_height: f64, + preferred_language: [*:0]const u8, }; // Suppress the per-cell "[s101] portrayed …" stderr summary (extern in lua_shim.c). @@ -263,6 +264,10 @@ pub const Context = struct { shallow_contour: f64 = 2, deep_contour: f64 = 30, safety_height: f64 = 0, + /// ISO 639-2 code the rules match featureName.language against. The + /// catalogue's GetFeatureName returns the entry whose language equals this + /// and falls back to the nameUsage 1 entry when none does. + preferred_language: [:0]const u8 = "eng", fn toC(self: Context) CContext { return .{ @@ -278,6 +283,7 @@ pub const Context = struct { .shallow_contour = self.shallow_contour, .deep_contour = self.deep_contour, .safety_height = self.safety_height, + .preferred_language = self.preferred_language.ptr, }; } }; @@ -426,6 +432,18 @@ pub const CellPortrayal = struct { /// FullLightLines=true pass over sectored-light points only (S-52 §12.2.4 /// full-length sector legs); the sect tag's variant axis. lights: ?[]const ?[]const u8 = null, + /// One pass per non-English featureName language the chart states, with + /// PreferredLanguage set to it, so GetFeatureName returns that language's + /// name where a feature has one. Empty when every name is English. Only the + /// label text differs from `base`, which is what scene bakes as the + /// per-language text twin. + national: []const LangStreams = &.{}, +}; + +/// A portrayal pass and the language it was run for. +pub const LangStreams = struct { + lang: []const u8, + streams: []const ?[]const u8, }; /// Portray a cell three ways so the client can toggle boundary style (areas) and @@ -470,5 +488,22 @@ pub fn portrayCellVariantsAdapted(arena: std.mem.Allocator, cell: *const s57.Cel cp.simplified = runAdapted(arena, cell, points.items, rules_dir, .{ .simplified_symbols = true }) catch null; if (lights.items.len > 0) cp.lights = runAdapted(arena, cell, lights.items, rules_dir, .{ .full_light_lines = true }) catch null; + // One pass per language the chart states. The catalogue compares + // featureName.language against PreferredLanguage, so each pass picks that + // language's name and leaves every other instruction as it was. + const langs = try adapter.languages(arena, adapted); + if (langs.len > 0) { + var passes = std.ArrayList(LangStreams).empty; + for (langs) |lang| { + var buf: [16]u8 = undefined; + if (lang.len >= buf.len) continue; + @memcpy(buf[0..lang.len], lang); + buf[lang.len] = 0; + const z: [:0]const u8 = buf[0..lang.len :0]; + const st = runAdapted(arena, cell, adapted, rules_dir, .{ .preferred_language = z }) catch continue; + try passes.append(arena, .{ .lang = lang, .streams = st }); + } + cp.national = passes.items; + } return cp; } diff --git a/src/render/ascii.zig b/src/render/ascii.zig index a12846a..0240f42 100644 --- a/src/render/ascii.zig +++ b/src/render/ascii.zig @@ -342,8 +342,12 @@ pub const AsciiSurface = struct { const self = sp(ctx); if (!self.cur_visible) return; if (!resolve.textGroupVisible(style.group, self.settings)) return; + // The label in the mariner's language. The scene fills style.national + // and replay reads it back from the tile, so both paths arrive here + // the same way. + const shown = rs.nationalFor(style.national, self.settings.preferred_language) orelse text; // First word only: a text grid earns its keep with placement, not prose. - const word = text[0 .. std.mem.indexOfScalar(u8, text, ' ') orelse text.len]; + const word = shown[0 .. std.mem.indexOfScalar(u8, shown, ' ') orelse shown.len]; if (word.len == 0) return; const cell = self.toCell(at); var col = cell.col; diff --git a/src/render/ascii_view_test.zig b/src/render/ascii_view_test.zig index d83c40d..f559690 100644 --- a/src/render/ascii_view_test.zig +++ b/src/render/ascii_view_test.zig @@ -125,3 +125,77 @@ test "ascii view: water shades left, land '#' right, coastline between" { try std.testing.expect(west == '▒' or west == '░' or west == '~' or west == '%'); try std.testing.expect(std.mem.indexOf(u8, text, "|") != null); } + +test "ascii view: the national name replaces the portrayed one when asked" { + // The tile path bakes a text property per language for a style to + // coalesce. A character surface picks at draw time instead. This drives the + // real rules and reads the label off the grid. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const z: u8 = 8; + const x: u32 = 128; + const y: u32 = 96; + const tb = tile.tileBoundsLonLat(z, x, y); + const w = tb[2] - tb[0]; + const h = tb[3] - tb[1]; + + // One BUAARE over the tile, named in both languages. The character grid + // keeps the first word of a label, so both names are single words. + const attrs = [_]s57.Attr{ + .{ .code = s57.ATTR_OBJNAM, .value = "Harwich" }, + .{ .code = s57.ATTR_NOBJNM, .value = "Harwijk" }, + }; + const feats = [_]s57.Feature{ + .{ .rcnm = 100, .rcid = 1, .prim = 3, .objl = 13, .attrs = &attrs }, // BUAARE + }; + var cell = s57.Cell{ + .params = .{}, + .vectors = &.{}, + .features = &feats, + .nodes = std.AutoHashMap(u64, s57.LonLat).init(a), + .edges = std.AutoHashMap(u32, usize).init(a), + .sounding_vecs = std.AutoHashMap(u64, usize).init(a), + .arena = std.heap.ArenaAllocator.init(std.testing.allocator), + }; + defer cell.arena.deinit(); + + const ring = [_]s57.LonLat{ + s57.LonLat.init(tb[0] + 0.1 * w, tb[1] + 0.1 * h), + s57.LonLat.init(tb[0] + 0.9 * w, tb[1] + 0.1 * h), + s57.LonLat.init(tb[0] + 0.9 * w, tb[1] + 0.9 * h), + s57.LonLat.init(tb[0] + 0.1 * w, tb[1] + 0.9 * h), + s57.LonLat.init(tb[0] + 0.1 * w, tb[1] + 0.1 * h), + }; + const geo = try a.alloc(?[][]s57.LonLat, 1); + const parts = try a.alloc([]s57.LonLat, 1); + parts[0] = try a.dupe(s57.LonLat, &ring); + geo[0] = parts; + + portray.setQuiet(true); + // The variants pass runs the rules again with PreferredLanguage set, which + // is what produces the national label. + const cp = try portray.portrayCellVariants(a, &cell, ""); + var colors = try render.resolve.Colors.init(a, colorprofile_registry.entries[0].bytes); + + const draw = struct { + fn run(al: std.mem.Allocator, c: *s57.Cell, st: portray.CellPortrayal, g: []?[][]s57.LonLat, col: *render.resolve.Colors, m: *const render.resolve.Settings, b: [4]f64) ![]const u8 { + var as = render.ascii.AsciiSurface.initView(al, col, .day, m, 8.0, 64, 32, 256.0, tile.EXTENT); + const nat = try al.alloc(scene.LangStreams, st.national.len); + for (st.national, nat) |p, *o| o.* = .{ .lang = p.lang, .streams = p.streams }; + const cells = [_]scene.CellRef{.{ .cell = c, .portrayal = st.base, .portrayal_national = nat, .geo = g }}; + return scene.generateView(&as, al, al, &cells, (b[0] + b[2]) / 2, (b[1] + b[3]) / 2, 8.0, false); + } + }.run; + + const off = render.resolve.Settings{}; + const plain = try draw(a, &cell, cp, geo, &colors, &off, tb); + try std.testing.expect(std.mem.indexOf(u8, plain, "Harwich") != null); + try std.testing.expect(std.mem.indexOf(u8, plain, "Harwijk") == null); + + const on = render.resolve.Settings{ .preferred_language = "und" }; + const national = try draw(a, &cell, cp, geo, &colors, &on, tb); + try std.testing.expect(std.mem.indexOf(u8, national, "Harwijk") != null); + try std.testing.expect(std.mem.indexOf(u8, national, "Harwich") == null); +} diff --git a/src/render/font.zig b/src/render/font.zig index f93f94f..bba589d 100644 --- a/src/render/font.zig +++ b/src/render/font.zig @@ -37,9 +37,24 @@ pub const Font = struct { glyf: usize, hmtx: usize, + /// `data` is a bare sfnt, or a TrueType collection, in which case the first + /// face is read. A collection starts with the tag `ttcf` and a table of + /// per-face offsets to the sfnt directories; the CJK faces a system ships + /// arrive this way. pub fn init(data: []const u8) !Font { if (data.len < 12) return error.BadFont; - const num_tables = u16At(data, 4); + var base: usize = 0; + if (std.mem.eql(u8, data[0..4], "ttcf")) { + if (data.len < 16) return error.BadFont; + if (u32At(data, 8) == 0) return error.BadFont; // face count + base = u32At(data, 12); + if (base + 12 > data.len) return error.BadFont; + } + return initAt(data, base); + } + + fn initAt(data: []const u8, base: usize) !Font { + const num_tables = u16At(data, base + 4); var head: usize = 0; var maxp: usize = 0; var hhea: usize = 0; @@ -48,7 +63,7 @@ pub const Font = struct { var glyf: usize = 0; var cmap: usize = 0; for (0..num_tables) |i| { - const rec = 12 + i * 16; + const rec = base + 12 + i * 16; if (rec + 16 > data.len) return error.BadFont; const tag = data[rec .. rec + 4]; const off = u32At(data, rec + 8); @@ -353,6 +368,21 @@ pub const Font = struct { /// The embedded label faces (Noto Sans, OFL 1.1 — see THIRD_PARTY_LICENSES.md). /// Regular is the base face; Bold and Italic give the label-tier resolver /// (src/style/labeltier.zig) real weight and slant rather than synthesizing them. +/// A face consulted for codepoints the bundled Noto Sans has no glyph for. +/// The bundled faces cover Latin, Greek and Cyrillic, so a chart naming its +/// features in another script draws boxes without one. A host installs it with +/// `setFallback`, which borrows the bytes for the life of the process. +/// +/// tile57 reads TILE57_FONT_FALLBACK in the render tools. The engine holds no +/// path itself, so a host that keeps its own font store passes those bytes. +pub var fallback: ?Font = null; + +/// Install the fallback face. `data` is a TrueType file or a collection, and it +/// has to outlive every render, because Font borrows it. +pub fn setFallback(data: []const u8) void { + fallback = Font.init(data) catch null; +} + pub const notosans = @embedFile("font_ttf"); pub const notosans_bold = @embedFile("font_ttf_bold"); pub const notosans_italic = @embedFile("font_ttf_italic"); diff --git a/src/render/pixel.zig b/src/render/pixel.zig index 04757a1..3b04cc1 100644 --- a/src/render/pixel.zig +++ b/src/render/pixel.zig @@ -501,6 +501,10 @@ pub const PixelSurface = struct { const self = sp(ctx); if (!self.cur_visible) return; if (!resolve.textGroupVisible(style.group, self.settings)) return; + // The label in the mariner's language. The scene fills style.national + // and replay reads it back from the tile, so both paths arrive here + // the same way. + const shown = rs.nationalFor(style.national, self.settings.preferred_language) orelse text; const font_px: f32 = @floatCast(if (style.font_size > 0) style.font_size else 12); // LocalOffset is millimetres, converted inside pushText at the S-52 // screen pitch (2.835 px/mm x device scale) — NOT via em units of the @@ -513,7 +517,7 @@ pub const PixelSurface = struct { const haloed = false; if (self.fnt == null) return; const face = self.pickFace(style.weight, style.slant); - try self.pushText(face.f, face.idx, text, font_px, if (style.halign.len > 0) style.halign else "center", if (style.valign.len > 0) style.valign else "middle", ox, oy, self.resolveColor(style.color), haloed, style.group, .{ + try self.pushText(face.f, face.idx, shown, font_px, if (style.halign.len > 0) style.halign else "center", if (style.valign.len > 0) style.valign else "middle", ox, oy, self.resolveColor(style.color), haloed, style.group, .{ .x = self.origin.x + @as(f32, @floatFromInt(at.x)) * self.scale, .y = self.origin.y + @as(f32, @floatFromInt(at.y)) * self.scale, }); @@ -521,6 +525,10 @@ pub const PixelSurface = struct { /// The parsed face + its cache index for a label's weight/slant, falling back /// to regular when the bold/italic face failed to load. + /// Glyph-cache face index for the fallback. 0, 1 and 2 are the bundled + /// regular, bold and italic faces. + const FALLBACK_FACE_IDX: u32 = 3; + fn pickFace(self: *PixelSurface, weight: fontmod.Weight, slant: fontmod.Slant) struct { f: *const fontmod.Font, idx: u32 } { if (weight == .bold) if (self.fnt_bold) |*b| return .{ .f = b, .idx = 1 }; if (slant == .italic) if (self.fnt_italic) |*i| return .{ .f = i, .idx = 2 }; @@ -546,12 +554,29 @@ pub const PixelSurface = struct { // Shape: glyph ids + pen positions (+ the PDF 1000/em advances). var gids = std.ArrayList(cv.Glyph).empty; + // The face each glyph came from, parallel to `gids`. A codepoint the + // run's face has no glyph for is drawn from the fallback face when one + // is installed, so a label in another script draws instead of boxes. + var faces = std.ArrayList(struct { f: *const fontmod.Font, idx: u32 }).empty; var pen: f32 = 0; var it = (std.unicode.Utf8View.init(text) catch return).iterator(); while (it.nextCodepoint()) |cp| { - const gid = f.glyphIndex(cp); - const adv = f.advance(gid); + var gf = f; + var gidx = face_idx; + var gid = f.glyphIndex(cp); + if (gid == 0) { + if (fontmod.fallback) |*fb| { + const fgid = fb.glyphIndex(cp); + if (fgid != 0) { + gf = fb; + gidx = FALLBACK_FACE_IDX; + gid = fgid; + } + } + } + const adv = gf.advance(gid); try gids.append(self.a, .{ .gid = gid, .cp = cp, .x = pen, .w1000 = @intFromFloat(std.math.clamp(@round(adv * 1000.0), 0, 65535)) }); + try faces.append(self.a, .{ .f = gf, .idx = gidx }); pen += adv * px; } if (gids.items.len == 0) return; @@ -571,8 +596,8 @@ pub const PixelSurface = struct { var rings = std.ArrayList([]const cv.Point).empty; var bbox = [4]f32{ std.math.floatMax(f32), std.math.floatMax(f32), -std.math.floatMax(f32), -std.math.floatMax(f32) }; - for (gids.items) |g| { - const contours = try self.glyphOutline(face, face_idx, g.gid); + for (gids.items, faces.items) |g, gf| { + const contours = try self.glyphOutline(gf.f, gf.idx, g.gid); for (contours) |contour| { const pts = try self.a.alloc(cv.Point, contour.len); for (contour, 0..) |p, i| { diff --git a/src/render/surface.zig b/src/render/surface.zig index 39fbec9..e7f53bb 100644 --- a/src/render/surface.zig +++ b/src/render/surface.zig @@ -63,6 +63,13 @@ pub fn fillToken(token: ColorToken) struct { name: []const u8, alpha: u8 } { /// "swept to N" note). Surfaces emit/draw only what is specified — the mvt /// surface serializes just text/color/size for a minimal label; a pixel /// surface uses its defaults. +/// One label in one language. `lang` is an ISO 639-2 code, or `und` for an +/// S-57 national name, because S-57 records no language for NOBJNM. +pub const NationalText = struct { + lang: []const u8, + text: []const u8, +}; + pub const TextStyle = struct { color: ColorToken, font_size: f64, @@ -73,6 +80,11 @@ pub const TextStyle = struct { offset_x: f64 = 0, // S-52 LocalOffset in mm (+x right / +y down) offset_y: f64 = 0, group: i64 = 0, // S-101 text group (§14.5) + /// This label in each language the chart states besides English. The scene + /// fills it from the per-language portrayal passes, a tile bakes each one + /// as `text_`, and replay reads them back, so both paths hand a + /// surface the same set. A surface picks by the mariner's language. + national: []const NationalText = &.{}, }; /// Per-feature S-52 metadata, bracketed around each feature's draw calls via @@ -80,6 +92,21 @@ pub const TextStyle = struct { /// surfaces need not import s57/s101. pub const BAND_UNKNOWN: u8 = 255; +/// The label a surface draws for the mariner's language `pref`, or null to +/// draw the portrayed string. An exact language match wins. Failing that, an +/// `und` entry answers any preference, because an S-57 cell states no language +/// for its national name and a mariner asking for one wants it. +pub fn nationalFor(alts: []const NationalText, pref: []const u8) ?[]const u8 { + if (pref.len == 0 or alts.len == 0) return null; + for (alts) |alt| { + if (std.mem.eql(u8, alt.lang, pref)) return alt.text; + } + for (alts) |alt| { + if (std.mem.eql(u8, alt.lang, "und")) return alt.text; + } + return null; +} + pub const FeatureMeta = struct { display_priority: i64 = 0, /// S-101 DisplayPlane: 0 UnderRadar (default), 1 OverRadar. Outranks diff --git a/src/s101/adapter.zig b/src/s101/adapter.zig index 2109166..a4b85f0 100644 --- a/src/s101/adapter.zig +++ b/src/s101/adapter.zig @@ -45,7 +45,7 @@ pub const CNode = struct { return cur; } - fn childList(self: *const CNode, code: []const u8) ?[]const CNode { + pub fn childList(self: *const CNode, code: []const u8) ?[]const CNode { for (self.children) |c| if (std.mem.eql(u8, c.code, code)) return c.nodes; return null; } @@ -778,6 +778,36 @@ fn buildSurveyDateRange(a: std.mem.Allocator, children: *std.ArrayList(ChildEntr } } +/// The distinct non-English featureName languages a chart states, in the order +/// they first appear. An S-57 cell yields at most `und`, because the adapter +/// tags NOBJNM that way and S-57 records no language for it. A native S-101 +/// dataset yields its real ISO 639-2 codes. +/// +/// Capped, because each one costs a portrayal pass at bake time and a text +/// property per label in the tile. A chart naming its features in more +/// languages than this keeps the first few. +pub const max_languages = 4; + +pub fn languages(a: std.mem.Allocator, adapted: []const Adapted) ![]const []const u8 { + var out = std.ArrayList([]const u8).empty; + for (adapted) |ad| { + const names = ad.root.childList("featureName") orelse continue; + for (names) |n| { + const lang = n.simpleValue("language") orelse continue; + if (n.simpleValue("name") == null) continue; + if (std.mem.eql(u8, lang, "eng")) continue; + var seen = false; + for (out.items) |h| { + if (std.mem.eql(u8, h, lang)) seen = true; + } + if (seen) continue; + try out.append(a, lang); + if (out.items.len == max_languages) return out.items; + } + } + return out.items; +} + /// Adapt all mappable features of a cell. Allocates into `a` (use an arena). pub fn adaptCell(a: std.mem.Allocator, cell: *const s57.Cell) ![]Adapted { var out = std.ArrayList(Adapted).empty; @@ -811,6 +841,7 @@ pub fn adaptCell(a: std.mem.Allocator, cell: *const s57.Cell) ![]Adapted { var attrs = std.ArrayList(NameVal).empty; var children = std.ArrayList(ChildEntry).empty; var name: []const u8 = ""; + var nat_name: []const u8 = ""; // M_QUAL deconstructs (S-65 §2.2.3.1): five S-57 attributes feed the proper // S-101 complexes below instead of the generic name-for-name loop. M_SREL -> // Quality of Survey shares the surveyDateRange piece (§2.2.3.2). @@ -824,7 +855,13 @@ pub fn adaptCell(a: std.mem.Allocator, cell: *const s57.Cell) ![]Adapted { // serve the trimmed value so numeric strings parse cleanly. const v = std.mem.trim(u8, at.value, " "); if (v.len == 0) continue; - if (at.code == s57.ATTR_OBJNAM) name = v; // OBJNAM -> featureName + // First match, matching s57.Feature.attr, which scene.zig reads to + // build the label twin. S-57 types both as single valued, and + // mergeNatf does not dedupe within one NATF field, so a cell + // repeating either code would otherwise give the model one value + // and the surface another. + if (at.code == s57.ATTR_OBJNAM and name.len == 0) name = v; // OBJNAM -> featureName + if (at.code == s57.ATTR_NOBJNM and nat_name.len == 0) nat_name = v; // NOBJNM -> featureName // Consumed by buildQualityOfBathymetricData: forwarding them flat would be // model-noise (SOUACC aliases the *complex* verticalUncertainty itself, // SURSTA/SUREND the bare dateStart/dateEnd, CATZOC the bare category). @@ -843,6 +880,8 @@ pub fn adaptCell(a: std.mem.Allocator, cell: *const s57.Cell) ![]Adapted { // framework then ignores (isComplex("information")). switch (at.code) { s57.ATTR_INFORM, s57.ATTR_TXTDSC => continue, + // NOBJNM feeds the featureName complex below, like OBJNAM. + s57.ATTR_NOBJNM => continue, else => {}, } if (catalogue.resolveAttrByCode(at.code)) |aname| { @@ -862,14 +901,28 @@ pub fn adaptCell(a: std.mem.Allocator, cell: *const s57.Cell) ![]Adapted { } } - // featureName[1] from OBJNAM. language + nameUsage are mandatory: the - // framework's GetFeatureName requires nameUsage (and prefers language=='eng'); - // without them PortrayFeatureName emits no text (mirrors Go complex.go:90-92). + // featureName from OBJNAM and NOBJNM. language + nameUsage are mandatory: + // the framework's GetFeatureName requires nameUsage (and matches language + // against contextParameters.PreferredLanguage); without them + // PortrayFeatureName emits no text (mirrors Go complex.go:90-92). + // + // OBJNAM takes nameUsage 1, the entry GetFeatureName falls back to when no + // language matches. S-57 does not record which language NOBJNM is written + // in, so it takes ISO 639-2 "und", undetermined. A cell carrying only + // NOBJNM gives it nameUsage 1, which is what makes that name render at all. + // Only one entry may hold nameUsage 1. if (name.len > 0) { const subs = try a.alloc(NameVal, 3); subs[0] = .{ .name = "name", .value = name }; subs[1] = .{ .name = "language", .value = "eng" }; - subs[2] = .{ .name = "nameUsage", .value = "1" }; // selected even if language differs + subs[2] = .{ .name = "nameUsage", .value = "1" }; + try appendChild(a, &children, "featureName", .{ .simple = subs }); + } + if (nat_name.len > 0) { + const subs = try a.alloc(NameVal, 3); + subs[0] = .{ .name = "name", .value = nat_name }; + subs[1] = .{ .name = "language", .value = "und" }; + subs[2] = .{ .name = "nameUsage", .value = if (name.len > 0) "2" else "1" }; try appendChild(a, &children, "featureName", .{ .simple = subs }); } // M_QUAL -> Quality of Bathymetric Data: deconstruct CATZOC into the @@ -2147,3 +2200,127 @@ test "Gap D: QualityOfSurvey restricts qualityOfHorizontalMeasurement to 4 (doub try std.testing.expectEqualStrings("QualityOfSurvey", adapted[0].code); try std.testing.expectEqual(@as(?[]const u8, null), adapted[0].root.simpleValue("qualityOfHorizontalMeasurement")); } + +test "NOBJNM becomes a featureName the portrayal can select" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // Two BUAARE: one with both names, one with the national name alone. + const both = [_]s57.Attr{ + .{ .code = s57.ATTR_OBJNAM, .value = "Shanghai" }, + .{ .code = s57.ATTR_NOBJNM, .value = "上海" }, + }; + const national_only = [_]s57.Attr{ + .{ .code = s57.ATTR_NOBJNM, .value = "日本" }, + }; + const feats = [_]s57.Feature{ + .{ .rcnm = 100, .rcid = 1, .prim = 3, .objl = 13, .attrs = &both }, + .{ .rcnm = 100, .rcid = 2, .prim = 3, .objl = 13, .attrs = &national_only }, + }; + var cell = s57.Cell{ + .params = .{}, + .vectors = &.{}, + .features = &feats, + .nodes = std.AutoHashMap(u64, s57.LonLat).init(a), + .edges = std.AutoHashMap(u32, usize).init(a), + .sounding_vecs = std.AutoHashMap(u64, usize).init(a), + .arena = std.heap.ArenaAllocator.init(std.testing.allocator), + }; + defer cell.arena.deinit(); + + const adapted = try adaptCell(a, &cell); + try std.testing.expectEqual(@as(usize, 2), adapted.len); + + // Both names present: OBJNAM holds nameUsage 1, so GetFeatureName returns it + // unless PreferredLanguage selects the other entry. + try std.testing.expectEqual(@as(usize, 2), adapted[0].root.childCount("featureName")); + const eng = adapted[0].root.resolve("featureName:1").?; + try std.testing.expectEqualStrings("Shanghai", eng.simpleValue("name").?); + try std.testing.expectEqualStrings("eng", eng.simpleValue("language").?); + try std.testing.expectEqualStrings("1", eng.simpleValue("nameUsage").?); + const nat = adapted[0].root.resolve("featureName:2").?; + try std.testing.expectEqualStrings("上海", nat.simpleValue("name").?); + try std.testing.expectEqualStrings("und", nat.simpleValue("language").?); + try std.testing.expectEqualStrings("2", nat.simpleValue("nameUsage").?); + + // National name alone: it takes nameUsage 1, which is what makes it render. + // The feature carried no name at all before. + try std.testing.expectEqual(@as(usize, 1), adapted[1].root.childCount("featureName")); + const only = adapted[1].root.resolve("featureName:1").?; + try std.testing.expectEqualStrings("日本", only.simpleValue("name").?); + try std.testing.expectEqualStrings("1", only.simpleValue("nameUsage").?); + + // NOBJNM feeds the complex, so it is not also forwarded as a flat attribute. + for (adapted[0].root.simple) |s| try std.testing.expect(!std.mem.eql(u8, s.value, "上海")); +} + +test "a repeated name attribute reads the same way the surface reads it" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // S-57 types OBJNAM and NOBJNM as single valued, and mergeNatf does not + // dedupe within one NATF field. s57.Feature.attr returns the first match + // and scene.zig reads it for the label twin, so the adapter takes the + // first too. + const dupes = [_]s57.Attr{ + .{ .code = s57.ATTR_OBJNAM, .value = "First" }, + .{ .code = s57.ATTR_OBJNAM, .value = "Second" }, + .{ .code = s57.ATTR_NOBJNM, .value = "Eerste" }, + .{ .code = s57.ATTR_NOBJNM, .value = "Tweede" }, + }; + const feats = [_]s57.Feature{ + .{ .rcnm = 100, .rcid = 1, .prim = 3, .objl = 13, .attrs = &dupes }, + }; + var cell = s57.Cell{ + .params = .{}, + .vectors = &.{}, + .features = &feats, + .nodes = std.AutoHashMap(u64, s57.LonLat).init(a), + .edges = std.AutoHashMap(u32, usize).init(a), + .sounding_vecs = std.AutoHashMap(u64, usize).init(a), + .arena = std.heap.ArenaAllocator.init(std.testing.allocator), + }; + defer cell.arena.deinit(); + + const adapted = try adaptCell(a, &cell); + try std.testing.expectEqualStrings("First", adapted[0].root.resolve("featureName:1").?.simpleValue("name").?); + try std.testing.expectEqualStrings("Eerste", adapted[0].root.resolve("featureName:2").?.simpleValue("name").?); + try std.testing.expectEqualStrings("First", feats[0].attr(s57.ATTR_OBJNAM).?); + try std.testing.expectEqualStrings("Eerste", feats[0].attr(s57.ATTR_NOBJNM).?); +} + +test "languages lists the non-English featureName languages" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const both = [_]s57.Attr{ + .{ .code = s57.ATTR_OBJNAM, .value = "Rossett Island" }, + .{ .code = s57.ATTR_NOBJNM, .value = "Rossett Inseln" }, + }; + const feats = [_]s57.Feature{.{ .rcnm = 100, .rcid = 1, .prim = 3, .objl = 13, .attrs = &both }}; + var cell = s57.Cell{ + .params = .{}, + .vectors = &.{}, + .features = &feats, + .nodes = std.AutoHashMap(u64, s57.LonLat).init(a), + .edges = std.AutoHashMap(u32, usize).init(a), + .sounding_vecs = std.AutoHashMap(u64, usize).init(a), + .arena = std.heap.ArenaAllocator.init(std.testing.allocator), + }; + defer cell.arena.deinit(); + + const adapted = try adaptCell(a, &cell); + const langs = try languages(a, adapted); + try std.testing.expectEqual(@as(usize, 1), langs.len); + try std.testing.expectEqualStrings("und", langs[0]); + + // Every name in English: no national pass to run. + const eng = [_]s57.Attr{.{ .code = s57.ATTR_OBJNAM, .value = "Boston" }}; + const f2 = [_]s57.Feature{.{ .rcnm = 100, .rcid = 2, .prim = 3, .objl = 13, .attrs = &eng }}; + cell.features = &f2; + const a2 = try adaptCell(a, &cell); + try std.testing.expectEqual(@as(usize, 0), (try languages(a, a2)).len); +} diff --git a/src/s57/s57.zig b/src/s57/s57.zig index 0c4f6ca..a688f88 100644 --- a/src/s57/s57.zig +++ b/src/s57/s57.zig @@ -515,6 +515,7 @@ pub const ATTR_QUASOU: u16 = 125; // quality of sounding measurement -> SNDFRM04 pub const ATTR_TECSOU: u16 = 156; // technique of sounding measurement -> SNDFRM04 swept B1 (4,18) pub const ATTR_STATUS: u16 = 149; // status -> SNDFRM04 low-accuracy ring when existence-doubtful (18) pub const ATTR_OBJNAM: u16 = 116; +pub const ATTR_NOBJNM: u16 = 301; // object name in national language (NATF) pub const ATTR_INFORM: u16 = 102; // information text -> `information` complex .text (ProcessNauticalInformation VG 90020) pub const ATTR_TXTDSC: u16 = 158; // external text-file name -> `information` complex .fileReference (VG 90021) pub const ATTR_CATZOC: u16 = 72; // M_QUAL category of zone of confidence @@ -1234,6 +1235,60 @@ fn parseAttrsKeepDel(a: Allocator, data: []const u8) ![]Attr { return parseAttrs(a, data, true); } +/// True when an ATTF/NATF field holds two bytes per character. +/// +/// S-57 clause 2.4 puts general text at lexical level 0, 1 or 2, and level 2 is +/// UCS-2. At that level the unit terminator is the two-byte code unit 0x001F, +/// so `1F 00` separates the values. A single-byte split resyncs one byte early +/// on such a field and reads every ATTL after the first from the wrong offset. +/// +/// DSSI states the level in NALL. A producer writing UCS-2 while leaving NALL +/// at 0 has been reported, so the encoding comes from the field's own shape: +/// every terminator followed by a NUL, and an even number of bytes between +/// terminators. A single-byte field matches only if every one of its attribute +/// codes is a multiple of 256 and every value has even length. +fn isDoubleByteField(data: []const u8) bool { + if (data.len < 4) return false; + var off: usize = 0; + var values: usize = 0; + var saw_nul = false; + while (off + 2 <= data.len) { + // At this level the field terminator is 1E 00, and the ISO 8211 layer + // strips a single-byte FT only, so the two bytes are still here. + if (data[off] == iso.FT) break; + off += 2; // ATTL + const end = std.mem.indexOfScalarPos(u8, data, off, iso.UT) orelse return false; + if ((end - off) % 2 != 0) return false; + values += 1; + if (end + 1 >= data.len) break; // the field ends at this terminator + if (data[end + 1] != 0x00) return false; + saw_nul = true; + off = end + 2; + } + // A single-byte field whose one value happens to have even length ends at + // its terminator with no NUL after it, so at least one terminator has to + // carry the second byte. + return values > 0 and saw_nul; +} + +/// UCS-2 (lexical level 2) attribute text as UTF-8. An unpaired surrogate or an +/// odd trailing byte reads as U+FFFD rather than failing the cell. +fn ucs2ToUtf8(a: Allocator, s: []const u8) ![]const u8 { + var out = std.ArrayList(u8).empty; + var i: usize = 0; + while (i + 1 < s.len) : (i += 2) { + const u = @as(u21, s[i]) | (@as(u21, s[i + 1]) << 8); + const cp: u21 = if (u >= 0xD800 and u <= 0xDFFF) 0xFFFD else u; + var buf: [4]u8 = undefined; + const n = std.unicode.utf8Encode(cp, &buf) catch { + try out.appendSlice(a, "\u{FFFD}"); + continue; + }; + try out.appendSlice(a, buf[0..n]); + } + return out.items; +} + fn parseAttrs(a: Allocator, data: []const u8, keep_del: bool) ![]Attr { var list = std.ArrayList(Attr).empty; // One attribute per UT terminator, so size the list once up front (absent / @@ -1245,6 +1300,23 @@ fn parseAttrs(a: Allocator, data: []const u8, keep_del: bool) ![]Attr { // the arena once and slice the values out of that copy — one dupe instead // of a validate+dupe per attribute value. Any byte >= 0x80 (national / // Latin-1 text needing transcoding) falls through to the per-value path. + // A UCS-2 field is framed differently, so it is split before the + // single-byte paths below. Latin text in UCS-2 is all bytes under 0x80 and + // would otherwise take the ASCII fast path and be mis-framed. + if (isDoubleByteField(data)) { + var off2: usize = 0; + while (off2 + 2 <= data.len) { + if (data[off2] == iso.FT) break; // the field's own 1E 00 + const code = u16le(data, off2); + off2 += 2; + const end = std.mem.indexOfScalarPos(u8, data, off2, iso.UT) orelse data.len; + const val = data[off2..end]; + if (val.len > 0 and (keep_del or !isDelMarker(val))) + try list.append(a, .{ .code = code, .value = try ucs2ToUtf8(a, val) }); + off2 = end + 2; // UT is two bytes at this level + } + return list.items; + } const all_ascii = blk: { for (data) |c| { if (c >= 0x80) break :blk false; @@ -3198,3 +3270,66 @@ test "a sounding-only cell reports an extent" { try std.testing.expectApproxEqAbs(b[2], pb[2], 1e-6); try std.testing.expectApproxEqAbs(b[3], pb[3], 1e-6); } + +test "a UCS-2 attribute field is framed and decoded two bytes per character" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // NOBJNM(301) = 上海, then OBJNAM(116) = Bay, both UCS-2 with the two-byte + // unit terminator. A producer stating NALL 0 while writing this has been + // reported, so the framing comes from the field. + const ucs2 = [_]u8{ 0x2D, 0x01, 0x0A, 0x4E, 0x77, 0x6D, iso.UT, 0x00 } ++ + [_]u8{ 0x74, 0x00, 'B', 0x00, 'a', 0x00, 'y', 0x00, iso.UT, 0x00 }; + try std.testing.expect(isDoubleByteField(&ucs2)); + const attrs = try parseATTF(a, &ucs2); + try std.testing.expectEqual(@as(usize, 2), attrs.len); + try std.testing.expectEqual(@as(u16, 301), attrs[0].code); + try std.testing.expectEqualStrings("\u{4E0A}\u{6D77}", attrs[0].value); + try std.testing.expectEqual(@as(u16, 116), attrs[1].code); + try std.testing.expectEqualStrings("Bay", attrs[1].value); + + // Latin text in UCS-2 is all bytes under 0x80, so the ASCII fast path would + // read it one byte at a time. + const latin = [_]u8{ 0x2D, 0x01, 'A', 0x00, 'B', 0x00, iso.UT, 0x00 }; + const la = try parseATTF(a, &latin); + try std.testing.expectEqual(@as(usize, 1), la.len); + try std.testing.expectEqualStrings("AB", la[0].value); +} + +test "a single-byte attribute field is left alone" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // One Latin-1 value of even length ending at the field's terminator. The + // shape a UCS-2 field has is a NUL after the terminator, which this lacks. + const one = [_]u8{ 0x2D, 0x01 } ++ "R\xF6ssett Inseln".* ++ [_]u8{iso.UT}; + try std.testing.expect(!isDoubleByteField(&one)); + const attrs = try parseATTF(a, &one); + try std.testing.expectEqual(@as(usize, 1), attrs.len); + try std.testing.expectEqualStrings("R\u{00F6}ssett Inseln", attrs[0].value); + + // Two ASCII values, the everyday shape. + const two = [_]u8{ 116, 0 } ++ "Bay".* ++ [_]u8{iso.UT} ++ [_]u8{ 75, 0 } ++ "3".* ++ [_]u8{iso.UT}; + try std.testing.expect(!isDoubleByteField(&two)); + const ta = try parseATTF(a, &two); + try std.testing.expectEqual(@as(usize, 2), ta.len); + try std.testing.expectEqualStrings("Bay", ta[0].value); + try std.testing.expectEqualStrings("3", ta[1].value); +} + +test "a UCS-2 field keeps its two-byte field terminator" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // The shape a real level 2 NATF has: the value, the two-byte UT, then the + // two-byte FT. parseFields strips a single-byte FT, so 1E 00 is still here. + const field = [_]u8{ 0x2C, 0x01, 0x2A, 0x6A, 0x99, 0x6C, 0x1A, 0x95, 0x30, 0x57, 0x7F, 0x89, 0x3A, 0x53, iso.UT, 0x00, iso.FT, 0x00 }; + try std.testing.expect(isDoubleByteField(&field)); + const attrs = try parseATTF(a, &field); + try std.testing.expectEqual(@as(usize, 1), attrs.len); + try std.testing.expectEqual(@as(u16, 300), attrs[0].code); + try std.testing.expectEqualStrings("\u{6A2A}\u{6C99}\u{951A}\u{5730}\u{897F}\u{533A}", attrs[0].value); +} diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig index 3579539..6742fb5 100644 --- a/src/scene/bake_enc.zig +++ b/src/scene/bake_enc.zig @@ -34,6 +34,7 @@ pub const Backend = struct { portrayal_plain: ?[]const ?[]const u8 = null, // PlainBoundaries variant (areas) portrayal_simplified: ?[]const ?[]const u8 = null, // SimplifiedSymbols variant (points) portrayal_lights: ?[]const ?[]const u8 = null, // FullLightLines variant (sectored lights) + portrayal_national: []const scene.LangStreams = &.{}, // one pass per national language geo: ?scene.GeoParts = null, // line/area geometry assembled once (buildGeoCache) geo_world: ?scene.GeoWorld = null, // world coords parallel to geo (cheap reprojection) feat_bbox: ?[]const ?[4]f64 = null, // per-feature bbox for the per-tile spatial cull @@ -756,7 +757,7 @@ const TileGenCtx = struct { // property — the renderer gates symbol declutter on it (render/gpu.zig // belowBandWindow). A cell with no compilation scale gets bandOf's 1:50k // default, the same default effScaminFloor uses above. - refs[nrefs] = .{ .cell = &be.cell, .portrayal = be.portrayal, .portrayal_plain = be.portrayal_plain, .portrayal_simplified = be.portrayal_simplified, .portrayal_lights = be.portrayal_lights, .geo = be.geo, .geo_world = be.geo_world, .feat_bbox = be.feat_bbox, .band = @intFromEnum(bandOf(be.cscl)), .suppress_fills = false, .suppress_patterns = false, .cover_clip = cover_clip, .suppress_lines = false, .suppress_points = false, .oscl = oscl, .overscale_hatch = !reach_only[j] and !holefill[j] and be.cscl > 0 and gf_tile < be.cscl and wins_somewhere, .eff_scamin_floor = effScaminFloor(be.cscl), .sounding_scamin = scene.soundingScamin(be.cscl), .light_range_m = be.light_range_m }; + refs[nrefs] = .{ .cell = &be.cell, .portrayal = be.portrayal, .portrayal_plain = be.portrayal_plain, .portrayal_simplified = be.portrayal_simplified, .portrayal_lights = be.portrayal_lights, .portrayal_national = be.portrayal_national, .geo = be.geo, .geo_world = be.geo_world, .feat_bbox = be.feat_bbox, .band = @intFromEnum(bandOf(be.cscl)), .suppress_fills = false, .suppress_patterns = false, .cover_clip = cover_clip, .suppress_lines = false, .suppress_points = false, .oscl = oscl, .overscale_hatch = !reach_only[j] and !holefill[j] and be.cscl > 0 and gf_tile < be.cscl and wins_somewhere, .eff_scamin_floor = effScaminFloor(be.cscl), .sounding_scamin = scene.soundingScamin(be.cscl), .light_range_m = be.light_range_m }; nrefs += 1; } const mvt_bytes = scene.encodeTile(scratch, scratch, refs[0..nrefs], z, x, y, c.format, c.pick_attrs) catch return; diff --git a/src/scene/replay.zig b/src/scene/replay.zig index 4ccb04c..743ac2d 100644 --- a/src/scene/replay.zig +++ b/src/scene/replay.zig @@ -82,6 +82,22 @@ fn metaFromProps(props: []const mvt.Prop) rs.FeatureMeta { /// Replay one decoded tile's layers as Surface calls (between the caller's /// begin/endScene). Layer names route exactly as TileSurface emitted them. +/// The `text_` properties a tile holds for one label. The key after +/// `text_` is the language code the scene baked it under. +fn nationalTexts(a: Allocator, props: []const mvt.Prop) ![]const rs.NationalText { + var out = std.ArrayList(rs.NationalText).empty; + for (props) |p| { + if (!std.mem.startsWith(u8, p.key, "text_")) continue; + const lang = p.key["text_".len..]; + if (lang.len == 0 or std.mem.eql(u8, lang, "ft")) continue; // the depth twin + switch (p.value) { + .string => |v| try out.append(a, .{ .lang = lang, .text = v }), + else => {}, + } + } + return out.items; +} + pub fn replayTile(a: Allocator, surf: rs.Surface, layers: []const mvt.DecodedLayer) !void { // Pre-scan the tile's depth-contour ladder (DEPCN valdco + DEPARE drval1) // so a render surface can snap the mariner's safety contour to the next @@ -217,6 +233,10 @@ pub fn replayTile(a: Allocator, surf: rs.Surface, layers: []const mvt.DecodedLay .offset_x = ox, .offset_y = oy, .group = propInt(f.properties, "tgrp", 0), + // Every language the bake stored beside the label, so a + // surface reading a bundle sees the same set the scene + // hands one reading a chart. + .national = try nationalTexts(a, f.properties), }; // A dredged area's depth was baked as its raw metres beside the // metric string, so the surface writes it in the mariner's unit. diff --git a/src/scene/scene.zig b/src/scene/scene.zig index cbc2622..77ee5a9 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -787,6 +787,12 @@ pub const TileSurface = struct { const s = sp(ctx); var props = std.ArrayList(mvt.Prop).empty; try appendTextProps(s.a, &props, text, text_style); // text already shortened by engine + // One property per language, keyed by its code, so a style selects + // with the mariner's own preference. + for (text_style.national) |alt| { + const key = try std.fmt.allocPrint(s.a, "text_{s}", .{alt.lang}); + try props.append(s.a, .{ .key = key, .value = .{ .string = alt.text } }); + } try appendMeta(s.a, &props, s.cur); const parts = try s.a.alloc([]const mvt.Point, 1); const single = try s.a.alloc(mvt.Point, 1); @@ -1247,6 +1253,27 @@ fn textStyleFor(t: instructions.Text, f: s57.Feature, fmeta: rs.FeatureMeta) rs. /// Serialize a text label's props in the tile schema order. `text` arrives already /// shortened/resolved by the engine. A minimal label (empty halign — see /// rs.TextStyle) carries only text/color/size, as the native fallbacks always did. +/// Text instruction `ti` in each language whose pass produced a different +/// string. The passes run the same rules over the same features, so the +/// instruction at index `ti` is the same instruction with another name +/// selected. A language that produced the portrayed string has no entry. +fn nationalTexts(a: Allocator, class: []const u8, passes: []const ParsedLang, ti: usize, base_text: []const u8) ![]const rs.NationalText { + var out = std.ArrayList(rs.NationalText).empty; + for (passes) |p| { + if (ti >= p.texts.len) continue; + const nt = p.texts[ti].text; + if (std.mem.eql(u8, nt, base_text)) continue; + try out.append(a, .{ .lang = p.lang, .text = try expandSeabedText(a, class, stripNameTag(nt)) }); + } + return out.items; +} + +/// One language's parsed text instructions for a feature. +const ParsedLang = struct { + lang: []const u8, + texts: []const instructions.Text, +}; + fn appendTextProps(a: Allocator, props: *std.ArrayList(mvt.Prop), text: []const u8, text_style: *const rs.TextStyle) !void { // Resolved body size: the FontSize modifier px, or 12 (oracle default). Drives // both the emitted font_size_px and the halo gate below. @@ -1694,32 +1721,40 @@ fn variantDiffers(base: []const u8, variant: ?[]const u8) bool { /// display variants) through the Surface: parse each pass and hand it to /// processFeatureParsed, splitting into two passes only when a variant differs /// (S-52 boundary §8.6.1 / point-symbol §11.2.2 axes -> the bnd/pts tags). -fn processFeatureInstr(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, geo_world: ?GeoWorld, instr: []const u8, plain: ?[]const u8, simplified: ?[]const u8, lights: ?[]const u8, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { +fn processFeatureInstr(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, geo_world: ?GeoWorld, instr: []const u8, plain: ?[]const u8, simplified: ?[]const u8, lights: ?[]const u8, national: []const LangStream, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { const base = try instructions.parse(a, instr); + // A national pass differs from the base only in the label text, so each + // one goes in as an alternative text list rather than a second drawn pass. + var nat_list = std.ArrayList(ParsedLang).empty; + for (national) |p| { + if (!variantDiffers(instr, p.stream)) continue; + try nat_list.append(a, .{ .lang = p.lang, .texts = (try instructions.parse(a, p.stream.?)).texts }); + } + const nat_texts: []const ParsedLang = nat_list.items; if (f.prim == 1) { // The sector-leg axis first: only LightSectored features carry a // lights variant, and none of those carries a simplified one, so the // two point axes never need a four-way split. if (variantDiffers(instr, lights)) { - try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, 2, 2, 0, z, x, y, tb, box, opts, surf); + try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, nat_texts, 2, 2, 0, z, x, y, tb, box, opts, surf); const lp = try instructions.parse(a, lights.?); - try processFeatureParsed(a, cell, f, fi, geo, geo_world, lp, 2, 2, 1, z, x, y, tb, box, opts, surf); + try processFeatureParsed(a, cell, f, fi, geo, geo_world, lp, nat_texts, 2, 2, 1, z, x, y, tb, box, opts, surf); } else if (variantDiffers(instr, simplified)) { - try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, 2, 0, 2, z, x, y, tb, box, opts, surf); + try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, nat_texts, 2, 0, 2, z, x, y, tb, box, opts, surf); const sp2 = try instructions.parse(a, simplified.?); - try processFeatureParsed(a, cell, f, fi, geo, geo_world, sp2, 2, 1, 2, z, x, y, tb, box, opts, surf); + try processFeatureParsed(a, cell, f, fi, geo, geo_world, sp2, nat_texts, 2, 1, 2, z, x, y, tb, box, opts, surf); } else { - try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, 2, 2, 2, z, x, y, tb, box, opts, surf); + try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, nat_texts, 2, 2, 2, z, x, y, tb, box, opts, surf); } return; } if (f.prim == 3 and variantDiffers(instr, plain)) { - try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, 1, 2, 2, z, x, y, tb, box, opts, surf); + try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, nat_texts, 1, 2, 2, z, x, y, tb, box, opts, surf); const pl = try instructions.parse(a, plain.?); - try processFeatureParsed(a, cell, f, fi, geo, geo_world, pl, 0, 2, 2, z, x, y, tb, box, opts, surf); + try processFeatureParsed(a, cell, f, fi, geo, geo_world, pl, nat_texts, 0, 2, 2, z, x, y, tb, box, opts, surf); return; } - try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, 2, 2, 2, z, x, y, tb, box, opts, surf); + try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, nat_texts, 2, 2, 2, z, x, y, tb, box, opts, surf); } // Web-mercator equatorial circumference (m): converts a ground-distance sector leg @@ -1848,7 +1883,7 @@ fn pickJson(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, pick: bool) return encodeS57Attrs(a, f) catch ""; } -fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, geo_world: ?GeoWorld, p: instructions.Portrayal, bnd: i64, pts: i64, sect: i64, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { +fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, geo_world: ?GeoWorld, p: instructions.Portrayal, nat_texts: []const ParsedLang, bnd: i64, pts: i64, sect: i64, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { const scamin = effScamin(f, opts); const cell_name = if (opts.pick_attrs) cell.name else ""; const fmeta = rs.FeatureMeta{ @@ -1910,10 +1945,12 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, try surf.drawSymbol(sym.symbol, pt, sym.rotation, SYMBOL_SCALE, sym.rot_north, .point, danger_depth); try surf.endFeature(); } - for (p.texts) |t| { - const ts = textStyleFor(t, f, fmeta); + for (p.texts, 0..) |t, ti| { + var ts = textStyleFor(t, f, fmeta); + const label = try expandSeabedText(a, fmeta.class, stripNameTag(t.text)); + ts.national = try nationalTexts(a, fmeta.class, nat_texts, ti, t.text); try surf.beginFeature(&fmeta); - try surf.drawText(try expandSeabedText(a, fmeta.class, stripNameTag(t.text)), &ts, pt); + try surf.drawText(label, &ts, pt); try surf.endFeature(); } } @@ -2040,9 +2077,10 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, if (featureAnchor(a, cell, f, fi, geo_parts)) |rp| { if (rp.lon() >= tb[0] and rp.lon() <= tb[2] and rp.lat() >= tb[1] and rp.lat() <= tb[3]) { const cpt = tile.project(rp.lon(), rp.lat(), z, x, y, tile.EXTENT); - for (p.texts) |t| { - const ts = textStyleFor(t, f, fmeta); + for (p.texts, 0..) |t, ti| { + var ts = textStyleFor(t, f, fmeta); const body = try expandSeabedText(a, fmeta.class, stripNameTag(t.text)); + ts.national = try nationalTexts(a, fmeta.class, nat_texts, ti, t.text); try surf.beginFeature(&fmeta); if (dredgedDepth(f, surf, body)) |d| { try surf.drawDepthText(d.value, d.trailer, &ts, cpt); @@ -2350,12 +2388,28 @@ fn emitOverscaleHatch(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, g /// is the default pass; `portrayal_plain` / `portrayal_simplified` are the /// boundary-style (area) and point-style (point) display variants (null when not /// computed) — see portray.CellPortrayal. +/// A portrayal pass and the language it was run for. +/// One language's stream for a single feature. +pub const LangStream = struct { + lang: []const u8, + stream: ?[]const u8, +}; + +pub const LangStreams = struct { + lang: []const u8, + streams: []const ?[]const u8, +}; + pub const CellRef = struct { cell: *s57.Cell, portrayal: ?[]const ?[]const u8 = null, portrayal_plain: ?[]const ?[]const u8 = null, portrayal_simplified: ?[]const ?[]const u8 = null, portrayal_lights: ?[]const ?[]const u8 = null, + /// One PreferredLanguage pass per language the chart states, each holding + /// the same instructions with that language's featureName selected. Only + /// the label text differs from `portrayal`. + portrayal_national: []const LangStreams = &.{}, geo: ?GeoParts = null, /// World coords parallel to `geo` (precomputed projection) — lets the baker /// reproject line/area geometry per tile without per-point tan/log. @@ -2436,7 +2490,7 @@ pub fn encodeTile(scratch: Allocator, out: Allocator, cells: []const CellRef, z: .light_range_m = cr.light_range_m, .only_fi = cr.only_fi, }; - try appendCellFeatures(a, surf, &mvt_surf, opts, cr.cell, cr.portrayal, cr.portrayal_plain, cr.portrayal_simplified, cr.portrayal_lights, cr.geo, cr.geo_world, cr.feat_bbox, z, x, y, tb, box); + try appendCellFeatures(a, surf, &mvt_surf, opts, cr.cell, cr.portrayal, cr.portrayal_plain, cr.portrayal_simplified, cr.portrayal_lights, cr.portrayal_national, cr.geo, cr.geo_world, cr.feat_bbox, z, x, y, tb, box); } return surf.endScene(out); @@ -2497,7 +2551,7 @@ pub fn appendTile(surf: rs.Surface, scratch: Allocator, cells: []const CellRef, .light_range_m = cr.light_range_m, .only_fi = cr.only_fi, }; - try appendCellFeatures(scratch, surf, null, opts, cr.cell, cr.portrayal, cr.portrayal_plain, cr.portrayal_simplified, cr.portrayal_lights, cr.geo, cr.geo_world, cr.feat_bbox, z, x, y, tb, box); + try appendCellFeatures(scratch, surf, null, opts, cr.cell, cr.portrayal, cr.portrayal_plain, cr.portrayal_simplified, cr.portrayal_lights, cr.portrayal_national, cr.geo, cr.geo_world, cr.feat_bbox, z, x, y, tb, box); } } @@ -2676,6 +2730,7 @@ fn appendCellFeatures( portrayal_plain: ?[]const ?[]const u8, portrayal_simplified: ?[]const ?[]const u8, portrayal_lights: ?[]const ?[]const u8, + portrayal_national: []const LangStreams, geo: ?GeoParts, geo_world: ?GeoWorld, feat_bbox: ?[]const ?[4]f64, @@ -2802,7 +2857,7 @@ fn appendCellFeatures( } if (f.objl == 163) { if (try symins.buildSyminsPortrayal(a, f)) |sp| { - try processFeatureParsed(a, cell.*, f, fi, geo, geo_world, sp, 2, 2, 2, z, x, y, tb, box, fopts, surf); + try processFeatureParsed(a, cell.*, f, fi, geo, geo_world, sp, &.{}, 2, 2, 2, z, x, y, tb, box, fopts, surf); continue; } } @@ -2813,7 +2868,12 @@ fn appendCellFeatures( const plain: ?[]const u8 = if (portrayal_plain) |pp| (if (fi < pp.len) pp[fi] else null) else null; const simplified: ?[]const u8 = if (portrayal_simplified) |pp| (if (fi < pp.len) pp[fi] else null) else null; const lights: ?[]const u8 = if (portrayal_lights) |pp| (if (fi < pp.len) pp[fi] else null) else null; - try processFeatureInstr(a, cell.*, f, fi, geo, geo_world, s, plain, simplified, lights, z, x, y, tb, box, fopts, surf); + var nat_streams = std.ArrayList(LangStream).empty; + for (portrayal_national) |p| { + if (fi < p.streams.len) try nat_streams.append(a, .{ .lang = p.lang, .stream = p.streams[fi] }); + } + const national: []const LangStream = nat_streams.items; + try processFeatureInstr(a, cell.*, f, fi, geo, geo_world, s, plain, simplified, lights, national, z, x, y, tb, box, fopts, surf); continue; } } @@ -3073,7 +3133,7 @@ test "processFeatureInstr routes SCAMIN point to the bucket + carries display_pr .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 1 }, .ornt = 255 }}, .attrs = &.{.{ .code = ATTR_SCAMIN, .value = "22000" }}, }; - try processFeatureInstr(a, cell, f_sc, 0, null, null, "DrawingPriority:7;PointInstruction:BOYLAT01", null, null, null, 0, 0, 0, tb, box, .{}, surf); + try processFeatureInstr(a, cell, f_sc, 0, null, null, "DrawingPriority:7;PointInstruction:BOYLAT01", null, null, null, &.{}, 0, 0, 0, tb, box, .{}, surf); try std.testing.expectEqual(@as(usize, 0), ms.points.items.len); try std.testing.expectEqual(@as(usize, 1), ms.points_scamin.items.len); try std.testing.expectEqual(@as(i64, 7), findProp(ms.points_scamin.items[0].properties, "display_priority").?.int); @@ -3087,7 +3147,7 @@ test "processFeatureInstr routes SCAMIN point to the bucket + carries display_pr .objl = 14, .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 1 }, .ornt = 255 }}, }; - try processFeatureInstr(a, cell, f_base, 0, null, null, "PointInstruction:BOYLAT01", null, null, null, 0, 0, 0, tb, box, .{}, surf); + try processFeatureInstr(a, cell, f_base, 0, null, null, "PointInstruction:BOYLAT01", null, null, null, &.{}, 0, 0, 0, tb, box, .{}, surf); try std.testing.expectEqual(@as(usize, 1), ms.points.items.len); try std.testing.expectEqual(@as(i64, 0), findProp(ms.points.items[0].properties, "display_priority").?.int); try std.testing.expectEqual(@as(?mvt.Value, null), findProp(ms.points.items[0].properties, "scamin")); @@ -3141,7 +3201,7 @@ test "processFeatureInstr tags pts 0/1 when a point's simplified symbol differs" .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 1 }, .ornt = 255 }}, }; // Paper -> BOYLAT01; simplified -> BOYLAT11. Two passes: pts=0 then pts=1. - try processFeatureInstr(a, cell, f, 0, null, null, "PointInstruction:BOYLAT01", null, "PointInstruction:BOYLAT11", null, 0, 0, 0, tb, box, .{}, surf); + try processFeatureInstr(a, cell, f, 0, null, null, "PointInstruction:BOYLAT01", null, "PointInstruction:BOYLAT11", null, &.{}, 0, 0, 0, tb, box, .{}, surf); try std.testing.expectEqual(@as(usize, 2), ms.points.items.len); try std.testing.expectEqual(@as(i64, 0), findProp(ms.points.items[0].properties, "pts").?.int); try std.testing.expectEqualStrings("BOYLAT01", findProp(ms.points.items[0].properties, "symbol_name").?.string); @@ -3190,7 +3250,7 @@ test "processFeatureInstr tags bnd 1/0 when an area's plain boundary differs" { // Symbolized boundary draws a complex line; plain draws a simple stroke. const symbolized = "ColorFill:DEPMS;LineStyle:CTNARE51,,1,CHMGD;LineInstruction:CTNARE51"; const plain = "ColorFill:DEPMS;LineStyle:_simple_,,1,CHMGD;LineInstruction:_simple_"; - try processFeatureInstr(a, cell, f, 0, geo_one, null, symbolized, plain, null, null, 0, 0, 0, tb, box, .{}, surf); + try processFeatureInstr(a, cell, f, 0, geo_one, null, symbolized, plain, null, null, &.{}, 0, 0, 0, tb, box, .{}, surf); // Both passes emit the fill: one tagged bnd=1 (symbolized), one bnd=0 (plain). try std.testing.expectEqual(@as(usize, 2), ms.areas.items.len); try std.testing.expectEqual(@as(i64, 1), findProp(ms.areas.items[0].properties, "bnd").?.int); @@ -3372,7 +3432,7 @@ test "DANGER01/02 on a VALSOU danger normalizes + tags danger_depth/sym_deep for .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 1 }, .ornt = 255 }}, .attrs = &.{.{ .code = s57.ATTR_VALSOU, .value = "15.1" }}, }; - try processFeatureInstr(a, cell, f_wreck, 0, null, null, "DrawingPriority:12;PointInstruction:DANGER02", null, null, null, 0, 0, 0, tb, box, .{}, surf); + try processFeatureInstr(a, cell, f_wreck, 0, null, null, "DrawingPriority:12;PointInstruction:DANGER02", null, null, null, &.{}, 0, 0, 0, tb, box, .{}, surf); try std.testing.expectEqual(@as(usize, 1), ms.points.items.len); try std.testing.expectEqualStrings("DANGER01", findProp(ms.points.items[0].properties, "symbol_name").?.string); try std.testing.expectEqual(@as(f64, 15.1), findProp(ms.points.items[0].properties, "danger_depth").?.double); @@ -3386,7 +3446,7 @@ test "DANGER01/02 on a VALSOU danger normalizes + tags danger_depth/sym_deep for .objl = 159, .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 1 }, .ornt = 255 }}, }; - try processFeatureInstr(a, cell, f_nodep, 0, null, null, "PointInstruction:DANGER01", null, null, null, 0, 0, 0, tb, box, .{}, surf); + try processFeatureInstr(a, cell, f_nodep, 0, null, null, "PointInstruction:DANGER01", null, null, null, &.{}, 0, 0, 0, tb, box, .{}, surf); try std.testing.expectEqual(@as(usize, 2), ms.points.items.len); try std.testing.expectEqualStrings("DANGER01", findProp(ms.points.items[1].properties, "symbol_name").?.string); try std.testing.expectEqual(@as(?mvt.Value, null), findProp(ms.points.items[1].properties, "danger_depth")); @@ -3400,7 +3460,7 @@ test "DANGER01/02 on a VALSOU danger normalizes + tags danger_depth/sym_deep for .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 1 }, .ornt = 255 }}, .attrs = &.{.{ .code = s57.ATTR_VALSOU, .value = "4" }}, }; - try processFeatureInstr(a, cell, f_buoy, 0, null, null, "PointInstruction:DANGER01", null, null, null, 0, 0, 0, tb, box, .{}, surf); + try processFeatureInstr(a, cell, f_buoy, 0, null, null, "PointInstruction:DANGER01", null, null, null, &.{}, 0, 0, 0, tb, box, .{}, surf); try std.testing.expectEqual(@as(usize, 3), ms.points.items.len); try std.testing.expectEqual(@as(?mvt.Value, null), findProp(ms.points.items[2].properties, "sym_deep")); } @@ -3508,3 +3568,42 @@ test "augmentV3: vz/ep/lt/iso/mq/lsk precomputes" { try augmentV3(a, &other, .area, 0); try std.testing.expectEqual(@as(?mvt.Value, null), findP(other[0].properties, "mq")); } + +test "nationalTexts reports the languages whose pass changed the label" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const zho = [_]instructions.Text{ + .{ .text = "\u{4e0a}\u{6d77}", .color = "CHBLK", .font_size = 10 }, + .{ .text = "Fl G 4s", .color = "CHBLK", .font_size = 10 }, + }; + const fin = [_]instructions.Text{ + .{ .text = "Shanghai", .color = "CHBLK", .font_size = 10 }, + .{ .text = "Fl G 4s", .color = "CHBLK", .font_size = 10 }, + }; + const passes = [_]ParsedLang{ .{ .lang = "zho", .texts = &zho }, .{ .lang = "fin", .texts = &fin } }; + + // Instruction 0 differs under zho and matches under fin. + const alts = try nationalTexts(a, "BUAARE", &passes, 0, "Shanghai"); + try std.testing.expectEqual(@as(usize, 1), alts.len); + try std.testing.expectEqualStrings("zho", alts[0].lang); + try std.testing.expectEqualStrings("\u{4e0a}\u{6d77}", alts[0].text); + + // Instruction 1 is the same string in every pass. + try std.testing.expectEqual(@as(usize, 0), (try nationalTexts(a, "LIGHTS", &passes, 1, "Fl G 4s")).len); + // A feature no pass covered, and an index past the texts. + try std.testing.expectEqual(@as(usize, 0), (try nationalTexts(a, "BUAARE", &.{}, 0, "Shanghai")).len); + try std.testing.expectEqual(@as(usize, 0), (try nationalTexts(a, "BUAARE", &passes, 5, "Shanghai")).len); +} + +test "nationalFor prefers the mariner's language and falls back to und" { + const alts = [_]rs.NationalText{ .{ .lang = "und", .text = "national" }, .{ .lang = "zho", .text = "\u{4e0a}\u{6d77}" } }; + try std.testing.expectEqualStrings("\u{4e0a}\u{6d77}", rs.nationalFor(&alts, "zho").?); + // S-57 states no language, so any preference reaches its national name. + try std.testing.expectEqualStrings("national", rs.nationalFor(&alts, "fin").?); + try std.testing.expect(rs.nationalFor(&alts, "") == null); + try std.testing.expect(rs.nationalFor(&.{}, "zho") == null); + const only_zho = [_]rs.NationalText{.{ .lang = "zho", .text = "\u{4e0a}\u{6d77}" }}; + try std.testing.expect(rs.nationalFor(&only_zho, "fin") == null); +} diff --git a/src/style/maplibre.zig b/src/style/maplibre.zig index 9e1c327..c4aedee 100644 --- a/src/style/maplibre.zig +++ b/src/style/maplibre.zig @@ -1004,7 +1004,7 @@ pub fn json(alloc: std.mem.Allocator, opts: Options) ![]u8 { .sound_img = try mariner.soundingsIconImage(b, &m), .point_img = try mariner.pointSymbolImage(b, &m), .contour_field = try mariner.contourLabelField(b, &m), - .text_field = try mariner.depthTextField(b, &m), + .text_field = try mariner.labelTextField(b, &m), .common = if (filters_on) try mariner.commonChartFilters(ba, &m, opts.enabled_bands, opts.now_unix) else &.{}, .text_group = if (filters_on) try mariner.textGroupFilter(b, &m) else null, .size_scale = opts.size_scale, @@ -1711,6 +1711,28 @@ test "buildFromTemplate: feet reads a dredged area's feet depth text; metres doe try std.testing.expect(std.mem.indexOf(u8, metres, "text_ft") == null); } +test "buildFromTemplate: a language preference reads that language's twin" { + const a = std.testing.allocator; + const zho = try buildFromTemplate(a, cs_template, &.{ .preferred_language = "zho" }, cs_ct, null, 1700000000); + defer a.free(zho); + try std.testing.expect(std.mem.indexOf(u8, zho, "text_zho") != null); + // An S-57 national name states no language, so it answers any preference. + try std.testing.expect(std.mem.indexOf(u8, zho, "text_und") != null); + + // No preference reads the string the rules composed. + const off = try buildFromTemplate(a, cs_template, &.{}, cs_ct, null, 1700000000); + defer a.free(off); + try std.testing.expect(std.mem.indexOf(u8, off, "text_zho") == null); + try std.testing.expect(std.mem.indexOf(u8, off, "text_und") == null); + + // The language wins over the depth twin, and both stay ahead of `text`. + const both = try buildFromTemplate(a, cs_template, &.{ .preferred_language = "zho", .depth_unit = .feet }, cs_ct, null, 1700000000); + defer a.free(both); + const izho = std.mem.indexOf(u8, both, "text_zho").?; + const ift = std.mem.indexOf(u8, both, "text_ft").?; + try std.testing.expect(izho < ift); +} + test "buildFromTemplate: enabled bands add a band filter" { const a = std.testing.allocator; const m = mariner.Settings{}; diff --git a/src/style/mariner.zig b/src/style/mariner.zig index 7b71672..390f3da 100644 --- a/src/style/mariner.zig +++ b/src/style/mariner.zig @@ -98,6 +98,14 @@ pub const Settings = struct { text_names: bool = true, show_light_descriptions: bool = true, text_other: bool = true, + /// The mariner's label language, as an ISO 639-2 code. Empty draws the + /// portrayed name. A code the chart states draws that language's name. Any + /// other code still draws an S-57 national name, because S-57 records no + /// language for NOBJNM and the adapter tags it `und`. + /// + /// The bake stores each language beside the portrayed name, so this + /// switches without a re-bake. + preferred_language: []const u8 = "", // -- viewing groups (S-52 §14.5, fine-grained per-VG control). A DENY-LIST: the // groups the mariner has turned OFF. null/empty = every viewing group shown @@ -442,14 +450,37 @@ pub fn contourLabelField(b: B, m: *const Settings) !Value { }); } -// A dredged area's depth text. DredgedArea composes it in metres, and the bake -// stores the feet twin beside it as text_ft. Feet reads that twin and falls back -// to the metric string, so a chart baked before the twin existed still labels -// its dredged areas. -pub fn depthTextField(b: B, m: *const Settings) !Value { - if (m.depth_unit != .feet) - return b.arr(&.{ b.s("coalesce"), try b.get("text"), b.s("") }); - return b.arr(&.{ b.s("coalesce"), try b.get("text_ft"), try b.get("text"), b.s("") }); +// The label text-field. The bake stores two twins beside the portrayed string: +// text_ft, a dredged area's depth in feet, and text_nat, a feature's +// national-language name. Each setting reads its twin and falls back to `text`, +// so a chart baked before a twin existed still labels. +// +// A feature has at most one of the two twins, so their relative order in the +// coalesce has no effect. +pub fn labelTextField(b: B, m: *const Settings) !Value { + var terms: [6]Value = undefined; + var n: usize = 0; + terms[n] = b.s("coalesce"); + n += 1; + if (m.preferred_language.len > 0) { + // The chart's own language first, then an S-57 national name, which + // states no language. The key is allocated, because the expression + // holds the slice after this returns. + const key = try std.fmt.allocPrint(b.a, "text_{s}", .{m.preferred_language}); + terms[n] = try b.get(key); + n += 1; + terms[n] = try b.get("text_und"); + n += 1; + } + if (m.depth_unit == .feet) { + terms[n] = try b.get("text_ft"); + n += 1; + } + terms[n] = try b.get("text"); + n += 1; + terms[n] = b.s(""); // coalesce always yields a value + n += 1; + return b.arr(terms[0..n]); } // ---- client-side display filters ------------------------------------------- diff --git a/tools/ascii.zig b/tools/ascii.zig index 8fbde40..8f50c7d 100644 --- a/tools/ascii.zig +++ b/tools/ascii.zig @@ -15,6 +15,15 @@ const cellPx = common.cellPx; // The chart on stdout as a Unicode text grid — the render-engine EXAMPLE // backend (src/render/ascii.zig): the same chart layer + view driver as // `tile57 png`, with the AsciiSurface at the end instead of the pixel one. +/// Install a fallback face for scripts the bundled Noto Sans has no glyphs for, +/// from the path in TILE57_FONT_FALLBACK. The bytes are leaked deliberately: +/// render.font borrows them for the life of the process. +fn loadFallbackFont(io: std.Io, a: std.mem.Allocator) void { + const p = std.c.getenv("TILE57_FONT_FALLBACK") orelse return; + const bytes = std.Io.Dir.cwd().readFileAlloc(io, std.mem.span(p), a, .limited(128 << 20)) catch return; + render.font.setFallback(bytes); +} + pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { if (args.len < 3) { std.debug.print("usage: tile57 ascii --view [--size COLSxROWS (default: terminal size)] [--palette day|dusk|night] [--ansi] [--tui] [--kitty] [--rules DIR]\n", .{}); @@ -31,6 +40,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var kitty = false; var view: ?struct { lon: f64, lat: f64, zoom: f64 } = null; var f = Flags{ .args = args, .i = 2 }; + var language: []const u8 = ""; while (f.next()) |arg| { if (std.mem.eql(u8, arg, "--view")) { const v = f.next() orelse return usageErr("--view needs lon,lat,zoom"); @@ -50,6 +60,8 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { palette = std.meta.stringToEnum(render.resolve.PaletteId, v) orelse return usageErr("palette must be day|dusk|night"); } else if (std.mem.eql(u8, arg, "--rules")) { rules = f.next() orelse return usageErr("--rules needs a dir"); + } else if (std.mem.eql(u8, arg, "--language")) { + language = f.next() orelse return usageErr("--language needs an ISO 639-2 code"); } else if (std.mem.eql(u8, arg, "--ansi")) { ansi = true; } else if (std.mem.eql(u8, arg, "--tui")) { @@ -90,7 +102,9 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } else chart.Chart.openPath(path, rules, false) catch return usageErr("cannot open source"); defer c.deinit(); + loadFallbackFont(io, a); var m = render.resolve.Settings{ .display_other = true }; + m.preferred_language = language; m.scheme = switch (palette) { .day => .day, .dusk => .dusk, diff --git a/tools/render.zig b/tools/render.zig index 6d759ea..2921bc8 100644 --- a/tools/render.zig +++ b/tools/render.zig @@ -38,6 +38,36 @@ fn archivePaths(io: std.Io, a: std.mem.Allocator, dir: []const u8) ![]const []co // PNG, or the same op stream -> PdfCanvas -> a deterministic vector PDF with // real text objects. A view renders ONE whole scene across every covering // tile (labels + declutter over the full canvas, no seams). +/// The language a live render portrays in. The mariner's code wins when the +/// chart states it. Failing that an S-57 national name answers, because S-57 +/// records no language for NOBJNM and the adapter tags it `und`. Written into +/// `buf` because the portrayal context takes a NUL-terminated string. +fn chartLanguage(a: std.mem.Allocator, adapted: []const engine.s101.adapter.Adapted, pref: []const u8, buf: *[16]u8) [:0]const u8 { + const langs = engine.s101.adapter.languages(a, adapted) catch return "eng"; + var pick: []const u8 = ""; + for (langs) |l| { + if (std.mem.eql(u8, l, pref)) pick = l; + } + if (pick.len == 0) { + for (langs) |l| { + if (std.mem.eql(u8, l, "und")) pick = l; + } + } + if (pick.len == 0 or pick.len >= buf.len) return "eng"; + @memcpy(buf[0..pick.len], pick); + buf[pick.len] = 0; + return buf[0..pick.len :0]; +} + +/// Install a fallback face for scripts the bundled Noto Sans has no glyphs for, +/// from the path in TILE57_FONT_FALLBACK. The bytes are leaked deliberately: +/// render.font borrows them for the life of the process. +fn loadFallbackFont(io: std.Io, a: std.mem.Allocator) void { + const p = std.c.getenv("TILE57_FONT_FALLBACK") orelse return; + const bytes = std.Io.Dir.cwd().readFileAlloc(io, std.mem.span(p), a, .limited(128 << 20)) catch return; + render.font.setFallback(bytes); +} + pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: render.pixel.Output) !void { if (args.len < 4) { std.debug.print("usage: tile57 {s} -o [--size N] [--palette day|dusk|night] [--rules DIR] [--dq] [--meta] [--scale F]\n" ++ @@ -112,6 +142,8 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: m.deep_contour = std.fmt.parseFloat(f64, v) catch return usageErr("bad --deep"); } else if (std.mem.eql(u8, arg, "--feet")) { m.depth_unit = .feet; + } else if (std.mem.eql(u8, arg, "--language")) { + m.preferred_language = f.next() orelse return usageErr("--language needs an ISO 639-2 code"); } else if (std.mem.eql(u8, arg, "--no-names")) { m.text_names = false; } else if (std.mem.eql(u8, arg, "--no-light-text")) { @@ -179,6 +211,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: // streaming chart loader and `tile57 explore`) — a bare-.000 render of a // real NOAA cell without its updates shows stale/deleted features. engine.portray.setQuiet(true); + loadFallbackFont(io, a); // LIVE portrayal context: the mariner's real safety contour / depth / // contours / styles evaluate INSIDE the rules — the native win over // the tile path's fixed bake context. @@ -193,13 +226,26 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: }; // A native S-101 dataset (.000, S-100 Part 10a) assembles + portrays without // the S-57 -> S-101 adapter; either format applies its .001.. update chain. + // A live render portrays once, so the national name is selected by + // portraying in that language rather than by baking a twin beside the + // label. Passing the language the cell states leaves a cell whose names + // are all English portraying as it did. + var lctx = pctx; + var lang_buf: [16]u8 = undefined; if (engine.s101.dataset.detect(data)) { const loaded = try engine.s101.native.parseDataset(a, data, readUpdates(io, a, path)); cell = loaded.cell; - streams = try engine.portray.portrayCellWithAdapted(a, &cell, loaded.adapted, resolveRulesDir(rules), pctx); + if (m.preferred_language.len > 0) lctx.preferred_language = chartLanguage(a, loaded.adapted, m.preferred_language, &lang_buf); + streams = try engine.portray.portrayCellWithAdapted(a, &cell, loaded.adapted, resolveRulesDir(rules), lctx); } else { cell = try engine.s57.parseCellWithUpdates(a, data, readUpdates(io, a, path)); - streams = try engine.portray.portrayCellWith(a, &cell, resolveRulesDir(rules), pctx); + if (m.preferred_language.len > 0) { + const ad = try engine.s101.adapter.adaptCell(a, &cell); + lctx.preferred_language = chartLanguage(a, ad, m.preferred_language, &lang_buf); + streams = try engine.portray.portrayCellWithAdapted(a, &cell, ad, resolveRulesDir(rules), lctx); + } else { + streams = try engine.portray.portrayCellWith(a, &cell, resolveRulesDir(rules), lctx); + } } } defer if (!from_bundle) cell.deinit();