From ba570f3386bcc3bfc5d69c3567dfb5c2596eafa2 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 01/13] Adapt NOBJNM into featureName The adapter built featureName from OBJNAM alone, so a feature carrying only a national language name portrayed no name, and a feature carrying both could portray only the English one. NOBJNM now becomes a second featureName entry. OBJNAM keeps nameUsage 1, the entry GetFeatureName falls back to when no language matches, so a cell with both names portrays as it did. A cell with NOBJNM alone gives that entry nameUsage 1, so the name renders. S-57 records no language for NOBJNM, so the entry uses ISO 639-2 "und", undetermined. Selecting it needs contextParameters.PreferredLanguage, which lua_shim.c fixes at 'eng'. Making that a mariner setting is a separate change. NOBJNM feeds the complex, so the flat attribute loop skips it, as it does for INFORM and TXTDSC. Four S-57 cells bake .pmtiles byte identical to main. None of them has a NOBJNM. --- src/s101/adapter.zig | 80 +++++++++++++++++++++++++++++++++++++++++--- src/s57/s57.zig | 1 + 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/s101/adapter.zig b/src/s101/adapter.zig index 2109166d..6f1b455d 100644 --- a/src/s101/adapter.zig +++ b/src/s101/adapter.zig @@ -811,6 +811,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). @@ -825,6 +826,7 @@ pub fn adaptCell(a: std.mem.Allocator, cell: *const s57.Cell) ![]Adapted { const v = std.mem.trim(u8, at.value, " "); if (v.len == 0) continue; if (at.code == s57.ATTR_OBJNAM) name = v; // OBJNAM -> featureName + if (at.code == s57.ATTR_NOBJNM) 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 +845,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 +866,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 +2165,57 @@ 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, "上海")); +} diff --git a/src/s57/s57.zig b/src/s57/s57.zig index 7ccc0465..2bd54ace 100644 --- a/src/s57/s57.zig +++ b/src/s57/s57.zig @@ -510,6 +510,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 From 29eb74fe76ff1947b9abd758643076ab6035fe49 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 02/13] Bake the national name beside the portrayed one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portrayal runs at bake time and GetFeatureName picks one string, so the label a host sees is fixed in the tile. Switching to the national name at runtime needs both strings there. The bake now stores NOBJNM as text_nat beside text, the way a dredged area's depth stores text_ft, and the national_names setting reads the twin through the same coalesce. depthTextField becomes labelTextField and composes both twins. A feature has at most one of them. The rules wrap a name, so the twin substitutes NOBJNM for the OBJNAM occurrence in the label rather than replacing the whole string. AnchorBerth's "Nr Shanghai" becomes "Nr 上海". A label that does not contain the feature's name gets no twin, which covers light descriptions and every other text instruction. FeatureMeta has both names because the surface sees the portrayed string alone. tile57_mariner gains national_names, appended. Three S-57 cells with no NATF data bake .pmtiles byte identical to main. A fourth, which has NATF data, grows 495 bytes. --- include/tile57.h | 7 +++++ src/capi.zig | 7 +++++ src/render/surface.zig | 5 ++++ src/scene/scene.zig | 63 ++++++++++++++++++++++++++++++++++++++++++ src/style/maplibre.zig | 21 +++++++++++++- src/style/mariner.zig | 37 +++++++++++++++++++------ 6 files changed, 131 insertions(+), 9 deletions(-) diff --git a/include/tile57.h b/include/tile57.h index 2a527025..1c5c7d2d 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -652,6 +652,13 @@ 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. */ + bool national_names; /* Label a feature with its national-language name + * (NOBJNM) where the cell carries one. The bake stores + * that name beside the portrayed one, so this switches + * without a re-bake. A feature with only NOBJNM is + * labelled with it whatever this says. + * Appended for ABI-append-safety; 0 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 80c47da5..28e92fd8 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -2264,6 +2264,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, + // Label a feature with its national-language name (NOBJNM) where the cell + // carries one. The bake stores that name beside the portrayed one as + // `text_nat`, so this switches without a re-bake. Appended for + // ABI-append-safety; a zeroed struct keeps the portrayed name. + national_names: bool, }; /// The tri-state `soundings` field as the engine's optional bool. @@ -2326,6 +2331,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, + .national_names = cm.national_names, .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 @@ -2510,6 +2516,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, + .national_names = d.national_names, }; } diff --git a/src/render/surface.zig b/src/render/surface.zig index 39fbec99..7ed7b742 100644 --- a/src/render/surface.zig +++ b/src/render/surface.zig @@ -97,6 +97,11 @@ pub const FeatureMeta = struct { // (AP(OVERSC01) over the cell's M_COVR coverage), shown only // while grossly overscale (denom < oscl, i.e. X2+) class: []const u8 = "", // S-57 object-class acronym (e.g. "LIGHTS") + /// OBJNAM and NOBJNM as the cell holds them. The portrayal rules pick one + /// string for the label, so the surface needs both to bake the national + /// twin beside it (scene.drawText). + name: []const u8 = "", + name_nat: []const u8 = "", s57_json: []const u8 = "", // cursor-pick blob: acronym->value JSON or "" cell_name: []const u8 = "", // source ENC cell name or "" /// Usage band (tiles.band.Band ordinal) of the SOURCE cell, or BAND_UNKNOWN diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 51f0060a..9be2f305 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -625,6 +625,8 @@ pub const TileSurface = struct { .scamin = meta.scamin, .oscl = meta.oscl, .class = meta.class, + .name = meta.name, + .name_nat = meta.name_nat, .s57 = meta.s57_json, .cell = meta.cell_name, .band = meta.band, @@ -787,6 +789,8 @@ 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 + if (try nationalTwin(s.a, text, s.cur)) |nat| + try props.append(s.a, .{ .key = "text_nat", .value = .{ .string = nat } }); 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); @@ -1127,6 +1131,9 @@ const Meta = struct { // fillPattern), NOT via appendMeta — points/lines/text don't need it. oscl: i64 = 0, class: []const u8 = "", // S-57 object-class acronym (M_QUAL, LIGHTS, …) + // OBJNAM and NOBJNM, for the national-language label twin (see nationalTwin). + name: []const u8 = "", + name_nat: []const u8 = "", // Cursor-pick report (S-52 §10.8) + dev feature inspector: the feature's full // S-57 attribute set as compact acronym->value JSON. "" = omitted (no reportable // attribute, or pick attributes disabled). See encodeS57Attrs / pickS57. @@ -1246,6 +1253,28 @@ 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. +/// BAKE: the national-language twin of a label, stored as `text_nat` beside +/// `text`, the way a dredged area's depth stores `text_ft`. Portrayal runs at +/// bake time and GetFeatureName picks one string, so a host switching language +/// at runtime needs both in the tile. +/// +/// The rules wrap a name ("Nr %s" in AnchorBerth), so the twin substitutes +/// NOBJNM for the OBJNAM occurrence rather than replacing the whole label. +/// Returns null when the feature carries no national name, when it carries no +/// OBJNAM (the label already IS the national name, adapter.zig gives it +/// nameUsage 1), or when the label does not contain the name, which is every +/// label that is not this feature's name. +fn nationalTwin(a: Allocator, text: []const u8, meta: Meta) !?[]const u8 { + if (meta.name_nat.len == 0 or meta.name.len == 0) return null; + const at = std.mem.indexOf(u8, text, meta.name) orelse return null; + const out = try a.alloc(u8, text.len - meta.name.len + meta.name_nat.len); + @memcpy(out[0..at], text[0..at]); + @memcpy(out[at..][0..meta.name_nat.len], meta.name_nat); + const tail = text[at + meta.name.len ..]; + @memcpy(out[at + meta.name_nat.len ..], tail); + return out; +} + 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. @@ -1858,6 +1887,8 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, .scamin = scamin, .oscl = opts.oscl, .class = pickClass(cell, f, fi), + .name = f.attr(s57.ATTR_OBJNAM) orelse "", + .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = cell_name, .band = opts.band, @@ -2123,6 +2154,8 @@ fn emitSweptAreaFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize .display_priority = 6, .scamin = effScamin(f, opts), .class = pickClass(cell, f, fi), + .name = f.attr(s57.ATTR_OBJNAM) orelse "", + .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = if (opts.pick_attrs) cell.name else "", .band = opts.band, @@ -2186,6 +2219,8 @@ fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize .display_priority = 12, .scamin = effScamin(f, opts), .class = pickClass(cell, f, fi), + .name = f.attr(s57.ATTR_OBJNAM) orelse "", + .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = if (opts.pick_attrs) cell.name else "", .band = opts.band, @@ -2279,6 +2314,8 @@ fn emitDashedBoundary(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, g .display_priority = 6, .scamin = effScamin(f, opts), .class = pickClass(cell, f, fi), + .name = f.attr(s57.ATTR_OBJNAM) orelse "", + .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = if (opts.pick_attrs) cell.name else "", .band = opts.band, @@ -2618,6 +2655,8 @@ fn emitCentredSymbol(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, ge .display_category = cat, .scamin = effScamin(f, opts), .class = pickClass(cell, f, fi), + .name = f.attr(s57.ATTR_OBJNAM) orelse "", + .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = if (opts.pick_attrs) cell.name else "", .band = opts.band, @@ -2649,6 +2688,8 @@ fn emitPickArea(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?G .display_category = 2, .scamin = effScamin(f, opts), .class = pickClass(cell, f, fi), + .name = f.attr(s57.ATTR_OBJNAM) orelse "", + .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = if (opts.pick_attrs) cell.name else "", .band = opts.band, @@ -3493,3 +3534,25 @@ test "augmentV3: vz/ep/lt/iso/mq/lsk precomputes" { try std.testing.expectEqual(@as(i64, 2998), findP(lns[0].properties, "lsk").?.int); try std.testing.expectEqual(@as(i64, 1), findP(lns[0].properties, "mq").?.int); } + +test "nationalTwin substitutes the national name inside the label" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const both = Meta{ .display_priority = 0, .name = "Shanghai", .name_nat = "上海" }; + // The plain label. + try std.testing.expectEqualStrings("上海", (try nationalTwin(a, "Shanghai", both)).?); + // A rule that wraps the name keeps its wrapper (AnchorBerth uses "Nr %s"). + try std.testing.expectEqualStrings("Nr 上海", (try nationalTwin(a, "Nr Shanghai", both)).?); + + // A label that is not this feature's name has no twin. + try std.testing.expect((try nationalTwin(a, "Fl G 4s", both)) == null); + // No national name. + const eng_only = Meta{ .display_priority = 0, .name = "Boston" }; + try std.testing.expect((try nationalTwin(a, "Boston", eng_only)) == null); + // National name alone: the portrayed label already IS it, adapter.zig gives + // that entry nameUsage 1. + const nat_only = Meta{ .display_priority = 0, .name_nat = "日本" }; + try std.testing.expect((try nationalTwin(a, "日本", nat_only)) == null); +} diff --git a/src/style/maplibre.zig b/src/style/maplibre.zig index 9e1c327b..7c21652f 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,25 @@ 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: national names read the NOBJNM twin" { + const a = std.testing.allocator; + const nat = try buildFromTemplate(a, cs_template, &.{ .national_names = true }, cs_ct, null, 1700000000); + defer a.free(nat); + try std.testing.expect(std.mem.indexOf(u8, nat, "text_nat") != null); + + // Off reads the string the rule composed, with no national twin in the style. + 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_nat") == null); + + // Both settings coalesce, national first. + const both = try buildFromTemplate(a, cs_template, &.{ .national_names = true, .depth_unit = .feet }, cs_ct, null, 1700000000); + defer a.free(both); + const inat = std.mem.indexOf(u8, both, "text_nat").?; + const ift = std.mem.indexOf(u8, both, "text_ft").?; + try std.testing.expect(inat < 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 496638e8..942a4103 100644 --- a/src/style/mariner.zig +++ b/src/style/mariner.zig @@ -98,6 +98,10 @@ pub const Settings = struct { text_names: bool = true, show_light_descriptions: bool = true, text_other: bool = true, + /// Label a feature with its national-language name (NOBJNM) where the cell + /// carries one. The bake stores that name beside the portrayed one, so this + /// switches without a re-bake. + national_names: bool = false, // -- 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 +446,31 @@ 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: [5]Value = undefined; + var n: usize = 0; + terms[n] = b.s("coalesce"); + n += 1; + if (m.national_names) { + terms[n] = try b.get("text_nat"); + 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 ------------------------------------------- From 3ea73184352aed1300b829ad36b385d236974919 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 03/13] Substitute the national name on the pixel and character surfaces A style coalesces text_nat on the tile path. The pixel, PDF and character surfaces read the portrayal instruction directly and never consult a style, so national_names had no effect on those outputs. They resolve it at draw time, the way drawDepthText resolves depth_unit. The substitution helper moves to render/surface.zig, where scene, pixel and ascii share it. ascii_view_test drives the real S-101 rules over a named BUAARE and reads the label off the grid: Harwich with the setting off, Harwijk with it on. --- src/render/ascii.zig | 9 ++++- src/render/ascii_view_test.zig | 70 ++++++++++++++++++++++++++++++++++ src/render/pixel.zig | 9 ++++- src/render/surface.zig | 25 ++++++++++-- src/scene/scene.zig | 36 ++++------------- 5 files changed, 115 insertions(+), 34 deletions(-) diff --git a/src/render/ascii.zig b/src/render/ascii.zig index a12846a0..523744a5 100644 --- a/src/render/ascii.zig +++ b/src/render/ascii.zig @@ -342,8 +342,15 @@ pub const AsciiSurface = struct { const self = sp(ctx); if (!self.cur_visible) return; if (!resolve.textGroupVisible(style.group, self.settings)) return; + // The national-language label, when the mariner selected it. The tile + // path bakes this as text_nat for a style to coalesce. A character grid + // resolves it here, the way it resolves depth_unit. + const shown = if (self.settings.national_names) + (try rs.nationalName(self.a, text, self.cur.name, self.cur.name_nat)) orelse text + else + 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 d83c40d8..7abd5100 100644 --- a/src/render/ascii_view_test.zig +++ b/src/render/ascii_view_test.zig @@ -125,3 +125,73 @@ 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 text_nat for a style to coalesce. A character + // surface substitutes 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); + const streams = try portray.portrayCell(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: []const ?[]const u8, 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 cells = [_]scene.CellRef{.{ .cell = c, .portrayal = st, .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, streams, 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{ .national_names = true }; + const national = try draw(a, &cell, streams, 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/pixel.zig b/src/render/pixel.zig index 04757a11..f189de3d 100644 --- a/src/render/pixel.zig +++ b/src/render/pixel.zig @@ -501,6 +501,13 @@ pub const PixelSurface = struct { const self = sp(ctx); if (!self.cur_visible) return; if (!resolve.textGroupVisible(style.group, self.settings)) return; + // The national-language label, when the mariner selected it. The tile + // path bakes this as text_nat for a style to coalesce. A pixel surface + // resolves it here, the way it resolves depth_unit. + const shown = if (self.settings.national_names) + (try rs.nationalName(self.a, text, self.cur.name, self.cur.name_nat)) orelse text + else + 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 +520,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, }); diff --git a/src/render/surface.zig b/src/render/surface.zig index 7ed7b742..7d7d8034 100644 --- a/src/render/surface.zig +++ b/src/render/surface.zig @@ -80,6 +80,25 @@ pub const TextStyle = struct { /// surfaces need not import s57/s101. pub const BAND_UNKNOWN: u8 = 255; +/// The national-language twin of a label: NOBJNM substituted for the OBJNAM +/// occurrence inside the portrayed string. A rule can wrap a name, as +/// AnchorBerth does with "Nr %s", so substituting keeps the wrapper. +/// +/// Returns null for a feature with no national name, for one with no OBJNAM +/// (the portrayed label is already the national name, because the adapter +/// gives that entry nameUsage 1), and for a label that does not contain the +/// name. Every text instruction other than this feature's name takes the last +/// case. +pub fn nationalName(a: std.mem.Allocator, text: []const u8, name: []const u8, name_nat: []const u8) !?[]const u8 { + if (name_nat.len == 0 or name.len == 0) return null; + const at = std.mem.indexOf(u8, text, name) orelse return null; + const out = try a.alloc(u8, text.len - name.len + name_nat.len); + @memcpy(out[0..at], text[0..at]); + @memcpy(out[at..][0..name_nat.len], name_nat); + @memcpy(out[at + name_nat.len ..], text[at + name.len ..]); + return out; +} + pub const FeatureMeta = struct { display_priority: i64 = 0, /// S-101 DisplayPlane: 0 UnderRadar (default), 1 OverRadar. Outranks @@ -97,9 +116,9 @@ pub const FeatureMeta = struct { // (AP(OVERSC01) over the cell's M_COVR coverage), shown only // while grossly overscale (denom < oscl, i.e. X2+) class: []const u8 = "", // S-57 object-class acronym (e.g. "LIGHTS") - /// OBJNAM and NOBJNM as the cell holds them. The portrayal rules pick one - /// string for the label, so the surface needs both to bake the national - /// twin beside it (scene.drawText). + /// OBJNAM and NOBJNM as the cell holds them. GetFeatureName returns one + /// string for the label, so a surface needs both to produce the national + /// twin (surface.nationalName). name: []const u8 = "", name_nat: []const u8 = "", s57_json: []const u8 = "", // cursor-pick blob: acronym->value JSON or "" diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 9be2f305..13132e7f 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -789,7 +789,7 @@ 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 - if (try nationalTwin(s.a, text, s.cur)) |nat| + if (try rs.nationalName(s.a, text, s.cur.name, s.cur.name_nat)) |nat| try props.append(s.a, .{ .key = "text_nat", .value = .{ .string = nat } }); try appendMeta(s.a, &props, s.cur); const parts = try s.a.alloc([]const mvt.Point, 1); @@ -1253,28 +1253,6 @@ 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. -/// BAKE: the national-language twin of a label, stored as `text_nat` beside -/// `text`, the way a dredged area's depth stores `text_ft`. Portrayal runs at -/// bake time and GetFeatureName picks one string, so a host switching language -/// at runtime needs both in the tile. -/// -/// The rules wrap a name ("Nr %s" in AnchorBerth), so the twin substitutes -/// NOBJNM for the OBJNAM occurrence rather than replacing the whole label. -/// Returns null when the feature carries no national name, when it carries no -/// OBJNAM (the label already IS the national name, adapter.zig gives it -/// nameUsage 1), or when the label does not contain the name, which is every -/// label that is not this feature's name. -fn nationalTwin(a: Allocator, text: []const u8, meta: Meta) !?[]const u8 { - if (meta.name_nat.len == 0 or meta.name.len == 0) return null; - const at = std.mem.indexOf(u8, text, meta.name) orelse return null; - const out = try a.alloc(u8, text.len - meta.name.len + meta.name_nat.len); - @memcpy(out[0..at], text[0..at]); - @memcpy(out[at..][0..meta.name_nat.len], meta.name_nat); - const tail = text[at + meta.name.len ..]; - @memcpy(out[at + meta.name_nat.len ..], tail); - return out; -} - 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. @@ -3535,24 +3513,24 @@ test "augmentV3: vz/ep/lt/iso/mq/lsk precomputes" { try std.testing.expectEqual(@as(i64, 1), findP(lns[0].properties, "mq").?.int); } -test "nationalTwin substitutes the national name inside the label" { +test "nationalName substitutes the national name inside the label" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); const both = Meta{ .display_priority = 0, .name = "Shanghai", .name_nat = "上海" }; // The plain label. - try std.testing.expectEqualStrings("上海", (try nationalTwin(a, "Shanghai", both)).?); + try std.testing.expectEqualStrings("上海", (try rs.nationalName(a, "Shanghai", both.name, both.name_nat)).?); // A rule that wraps the name keeps its wrapper (AnchorBerth uses "Nr %s"). - try std.testing.expectEqualStrings("Nr 上海", (try nationalTwin(a, "Nr Shanghai", both)).?); + try std.testing.expectEqualStrings("Nr 上海", (try rs.nationalName(a, "Nr Shanghai", both.name, both.name_nat)).?); // A label that is not this feature's name has no twin. - try std.testing.expect((try nationalTwin(a, "Fl G 4s", both)) == null); + try std.testing.expect((try rs.nationalName(a, "Fl G 4s", both.name, both.name_nat)) == null); // No national name. const eng_only = Meta{ .display_priority = 0, .name = "Boston" }; - try std.testing.expect((try nationalTwin(a, "Boston", eng_only)) == null); + try std.testing.expect((try rs.nationalName(a, "Boston", eng_only.name, eng_only.name_nat)) == null); // National name alone: the portrayed label already IS it, adapter.zig gives // that entry nameUsage 1. const nat_only = Meta{ .display_priority = 0, .name_nat = "日本" }; - try std.testing.expect((try nationalTwin(a, "日本", nat_only)) == null); + try std.testing.expect((try rs.nationalName(a, "日本", nat_only.name, nat_only.name_nat)) == null); } From add821f97adad9ad5fa5faa21e45ec3cd8fa987f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 04/13] Add --national-names to the png, pdf and ascii tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render tools already expose mariner settings as flags, including --feet and --no-names. national_names had none, so the new setting could only be exercised through the C ABI or a unit test. On a cell with both names, an LNDARE labels "Rossett Island" by default and "Rössett Inseln" with the flag. A BUAARE in the same view with no NOBJNM stays as it is. --- tools/ascii.zig | 4 ++++ tools/render.zig | 2 ++ 2 files changed, 6 insertions(+) diff --git a/tools/ascii.zig b/tools/ascii.zig index 8fbde40e..c517aff1 100644 --- a/tools/ascii.zig +++ b/tools/ascii.zig @@ -31,6 +31,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 national = false; 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 +51,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, "--national-names")) { + national = true; } else if (std.mem.eql(u8, arg, "--ansi")) { ansi = true; } else if (std.mem.eql(u8, arg, "--tui")) { @@ -91,6 +94,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { defer c.deinit(); var m = render.resolve.Settings{ .display_other = true }; + m.national_names = national; m.scheme = switch (palette) { .day => .day, .dusk => .dusk, diff --git a/tools/render.zig b/tools/render.zig index 6d759ea6..9714c7c7 100644 --- a/tools/render.zig +++ b/tools/render.zig @@ -112,6 +112,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, "--national-names")) { + m.national_names = true; } else if (std.mem.eql(u8, arg, "--no-names")) { m.text_names = false; } else if (std.mem.eql(u8, arg, "--no-light-text")) { From 7fd45e3b26f582a71e6e8610313bb2cd89bf7f26 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 05/13] Move the label twin onto TextStyle so replay reaches it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pixel and character surfaces substituted the national name from FeatureMeta, which only the direct portrayal path fills. A surface reading a baked bundle saw empty names and drew the portrayed label whatever the setting said. TextStyle gains a national field. The scene fills it beside the label it emits, TileSurface bakes it as text_nat, and replay reads it back, so a surface sees the same pair on either path. This follows drawDepthText, where the raw metres are baked beside the string and the surface formats them. FeatureMeta drops name and name_nat. tile57 ascii over a baked bundle prints Rossett twice by default, and Rossett plus Rössett with the flag. The second is the LNDARE with a NOBJNM. The BUAARE of the same name has none and is unchanged. --- src/render/ascii.zig | 10 +++--- src/render/pixel.zig | 10 +++--- src/render/surface.zig | 10 +++--- src/s101/adapter.zig | 45 ++++++++++++++++++++++++-- src/scene/replay.zig | 4 +++ src/scene/scene.zig | 72 +++++++++++++++++++++--------------------- 6 files changed, 98 insertions(+), 53 deletions(-) diff --git a/src/render/ascii.zig b/src/render/ascii.zig index 523744a5..8b42276d 100644 --- a/src/render/ascii.zig +++ b/src/render/ascii.zig @@ -342,11 +342,11 @@ pub const AsciiSurface = struct { const self = sp(ctx); if (!self.cur_visible) return; if (!resolve.textGroupVisible(style.group, self.settings)) return; - // The national-language label, when the mariner selected it. The tile - // path bakes this as text_nat for a style to coalesce. A character grid - // resolves it here, the way it resolves depth_unit. - const shown = if (self.settings.national_names) - (try rs.nationalName(self.a, text, self.cur.name, self.cur.name_nat)) orelse text + // The national-language label, when the mariner selected it. The scene + // fills style.national and replay reads it back from the tile, so both + // paths arrive here the same way. + const shown = if (self.settings.national_names and style.national.len > 0) + style.national else text; // First word only: a text grid earns its keep with placement, not prose. diff --git a/src/render/pixel.zig b/src/render/pixel.zig index f189de3d..77951e5f 100644 --- a/src/render/pixel.zig +++ b/src/render/pixel.zig @@ -501,11 +501,11 @@ pub const PixelSurface = struct { const self = sp(ctx); if (!self.cur_visible) return; if (!resolve.textGroupVisible(style.group, self.settings)) return; - // The national-language label, when the mariner selected it. The tile - // path bakes this as text_nat for a style to coalesce. A pixel surface - // resolves it here, the way it resolves depth_unit. - const shown = if (self.settings.national_names) - (try rs.nationalName(self.a, text, self.cur.name, self.cur.name_nat)) orelse text + // The national-language label, when the mariner selected it. The scene + // fills style.national and replay reads it back from the tile, so both + // paths arrive here the same way. + const shown = if (self.settings.national_names and style.national.len > 0) + style.national else text; const font_px: f32 = @floatCast(if (style.font_size > 0) style.font_size else 12); diff --git a/src/render/surface.zig b/src/render/surface.zig index 7d7d8034..7f4d88d0 100644 --- a/src/render/surface.zig +++ b/src/render/surface.zig @@ -73,6 +73,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) + /// The national-language twin of this label ("" when the feature has no + /// NOBJNM). The scene fills it, a tile bakes it as `text_nat`, and replay + /// reads it back, so both paths hand a surface the same pair. This mirrors + /// the depth twin, where the raw metres go in `drawDepthText`. + national: []const u8 = "", }; /// Per-feature S-52 metadata, bracketed around each feature's draw calls via @@ -116,11 +121,6 @@ pub const FeatureMeta = struct { // (AP(OVERSC01) over the cell's M_COVR coverage), shown only // while grossly overscale (denom < oscl, i.e. X2+) class: []const u8 = "", // S-57 object-class acronym (e.g. "LIGHTS") - /// OBJNAM and NOBJNM as the cell holds them. GetFeatureName returns one - /// string for the label, so a surface needs both to produce the national - /// twin (surface.nationalName). - name: []const u8 = "", - name_nat: []const u8 = "", s57_json: []const u8 = "", // cursor-pick blob: acronym->value JSON or "" cell_name: []const u8 = "", // source ENC cell name or "" /// Usage band (tiles.band.Band ordinal) of the SOURCE cell, or BAND_UNKNOWN diff --git a/src/s101/adapter.zig b/src/s101/adapter.zig index 6f1b455d..d03668b0 100644 --- a/src/s101/adapter.zig +++ b/src/s101/adapter.zig @@ -825,8 +825,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 - if (at.code == s57.ATTR_NOBJNM) nat_name = v; // NOBJNM -> 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). @@ -2219,3 +2224,39 @@ test "NOBJNM becomes a featureName the portrayal can select" { // 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).?); +} diff --git a/src/scene/replay.zig b/src/scene/replay.zig index 4ccb04c7..ca0bbf9e 100644 --- a/src/scene/replay.zig +++ b/src/scene/replay.zig @@ -217,6 +217,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), + // The national twin the bake stored beside the label, so a + // surface reading a bundle sees the same pair the scene + // hands one reading a chart. + .national = propStr(f.properties, "text_nat"), }; // 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 13132e7f..5a0c24aa 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -625,8 +625,6 @@ pub const TileSurface = struct { .scamin = meta.scamin, .oscl = meta.oscl, .class = meta.class, - .name = meta.name, - .name_nat = meta.name_nat, .s57 = meta.s57_json, .cell = meta.cell_name, .band = meta.band, @@ -789,8 +787,8 @@ 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 - if (try rs.nationalName(s.a, text, s.cur.name, s.cur.name_nat)) |nat| - try props.append(s.a, .{ .key = "text_nat", .value = .{ .string = nat } }); + if (text_style.national.len > 0) + try props.append(s.a, .{ .key = "text_nat", .value = .{ .string = text_style.national } }); 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); @@ -1131,9 +1129,6 @@ const Meta = struct { // fillPattern), NOT via appendMeta — points/lines/text don't need it. oscl: i64 = 0, class: []const u8 = "", // S-57 object-class acronym (M_QUAL, LIGHTS, …) - // OBJNAM and NOBJNM, for the national-language label twin (see nationalTwin). - name: []const u8 = "", - name_nat: []const u8 = "", // Cursor-pick report (S-52 §10.8) + dev feature inspector: the feature's full // S-57 attribute set as compact acronym->value JSON. "" = omitted (no reportable // attribute, or pick attributes disabled). See encodeS57Attrs / pickS57. @@ -1253,6 +1248,14 @@ 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. +/// The national twin of `label` for `f`, or "" when the feature has no NOBJNM +/// and when the label does not hold the feature's OBJNAM. +fn nationalLabel(a: Allocator, label: []const u8, f: s57.Feature) ![]const u8 { + const name = f.attr(s57.ATTR_OBJNAM) orelse return ""; + const nat = f.attr(s57.ATTR_NOBJNM) orelse return ""; + return (try rs.nationalName(a, label, name, nat)) orelse ""; +} + 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. @@ -1865,8 +1868,6 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, .scamin = scamin, .oscl = opts.oscl, .class = pickClass(cell, f, fi), - .name = f.attr(s57.ATTR_OBJNAM) orelse "", - .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = cell_name, .band = opts.band, @@ -1919,9 +1920,11 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, try surf.endFeature(); } for (p.texts) |t| { - const ts = textStyleFor(t, f, fmeta); + var ts = textStyleFor(t, f, fmeta); + const label = try expandSeabedText(a, fmeta.class, stripNameTag(t.text)); + ts.national = try nationalLabel(a, label, f); 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(); } } @@ -2049,8 +2052,9 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, 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); + var ts = textStyleFor(t, f, fmeta); const body = try expandSeabedText(a, fmeta.class, stripNameTag(t.text)); + ts.national = try nationalLabel(a, body, f); try surf.beginFeature(&fmeta); if (dredgedDepth(f, surf, body)) |d| { try surf.drawDepthText(d.value, d.trailer, &ts, cpt); @@ -2132,8 +2136,6 @@ fn emitSweptAreaFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize .display_priority = 6, .scamin = effScamin(f, opts), .class = pickClass(cell, f, fi), - .name = f.attr(s57.ATTR_OBJNAM) orelse "", - .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = if (opts.pick_attrs) cell.name else "", .band = opts.band, @@ -2197,8 +2199,6 @@ fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize .display_priority = 12, .scamin = effScamin(f, opts), .class = pickClass(cell, f, fi), - .name = f.attr(s57.ATTR_OBJNAM) orelse "", - .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = if (opts.pick_attrs) cell.name else "", .band = opts.band, @@ -2292,8 +2292,6 @@ fn emitDashedBoundary(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, g .display_priority = 6, .scamin = effScamin(f, opts), .class = pickClass(cell, f, fi), - .name = f.attr(s57.ATTR_OBJNAM) orelse "", - .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = if (opts.pick_attrs) cell.name else "", .band = opts.band, @@ -2633,8 +2631,6 @@ fn emitCentredSymbol(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, ge .display_category = cat, .scamin = effScamin(f, opts), .class = pickClass(cell, f, fi), - .name = f.attr(s57.ATTR_OBJNAM) orelse "", - .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = if (opts.pick_attrs) cell.name else "", .band = opts.band, @@ -2666,8 +2662,6 @@ fn emitPickArea(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?G .display_category = 2, .scamin = effScamin(f, opts), .class = pickClass(cell, f, fi), - .name = f.attr(s57.ATTR_OBJNAM) orelse "", - .name_nat = f.attr(s57.ATTR_NOBJNM) orelse "", .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = if (opts.pick_attrs) cell.name else "", .band = opts.band, @@ -3513,24 +3507,30 @@ test "augmentV3: vz/ep/lt/iso/mq/lsk precomputes" { try std.testing.expectEqual(@as(i64, 1), findP(lns[0].properties, "mq").?.int); } -test "nationalName substitutes the national name inside the label" { +test "nationalLabel substitutes the national name inside the label" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); - const both = Meta{ .display_priority = 0, .name = "Shanghai", .name_nat = "上海" }; - // The plain label. - try std.testing.expectEqualStrings("上海", (try rs.nationalName(a, "Shanghai", both.name, both.name_nat)).?); - // A rule that wraps the name keeps its wrapper (AnchorBerth uses "Nr %s"). - try std.testing.expectEqualStrings("Nr 上海", (try rs.nationalName(a, "Nr Shanghai", both.name, both.name_nat)).?); + const both = [_]s57.Attr{ + .{ .code = s57.ATTR_OBJNAM, .value = "Shanghai" }, + .{ .code = s57.ATTR_NOBJNM, .value = "\u{4e0a}\u{6d77}" }, + }; + const f = s57.Feature{ .rcnm = 100, .rcid = 1, .prim = 3, .objl = 13, .attrs = &both }; + try std.testing.expectEqualStrings("\u{4e0a}\u{6d77}", try nationalLabel(a, "Shanghai", f)); + // A rule that wraps the name keeps its wrapper (AnchorBerth uses "Nr %s"). + try std.testing.expectEqualStrings("Nr \u{4e0a}\u{6d77}", try nationalLabel(a, "Nr Shanghai", f)); // A label that is not this feature's name has no twin. - try std.testing.expect((try rs.nationalName(a, "Fl G 4s", both.name, both.name_nat)) == null); - // No national name. - const eng_only = Meta{ .display_priority = 0, .name = "Boston" }; - try std.testing.expect((try rs.nationalName(a, "Boston", eng_only.name, eng_only.name_nat)) == null); - // National name alone: the portrayed label already IS it, adapter.zig gives - // that entry nameUsage 1. - const nat_only = Meta{ .display_priority = 0, .name_nat = "日本" }; - try std.testing.expect((try rs.nationalName(a, "日本", nat_only.name, nat_only.name_nat)) == null); + try std.testing.expectEqualStrings("", try nationalLabel(a, "Fl G 4s", f)); + + const eng_only = [_]s57.Attr{.{ .code = s57.ATTR_OBJNAM, .value = "Boston" }}; + const fe = s57.Feature{ .rcnm = 100, .rcid = 2, .prim = 3, .objl = 13, .attrs = &eng_only }; + try std.testing.expectEqualStrings("", try nationalLabel(a, "Boston", fe)); + + // National name alone: the portrayed label already is it, because the + // adapter gives that entry nameUsage 1. + const nat_only = [_]s57.Attr{.{ .code = s57.ATTR_NOBJNM, .value = "\u{65e5}\u{672c}" }}; + const fn_ = s57.Feature{ .rcnm = 100, .rcid = 3, .prim = 3, .objl = 13, .attrs = &nat_only }; + try std.testing.expectEqualStrings("", try nationalLabel(a, "\u{65e5}\u{672c}", fn_)); } From 383234c5d3c9523d94555fdce1f2fc2a87765b9e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 06/13] Document the national name in the tile schema, ABI and limitations tile-schema.md gains the text_nat property on the text layer. api/style.md gains the national_names field. rendering.md lists --national-names with the other mariner flags. limitations.md records that S-57 stores one NOBJNM and no language for it, so the converted featureName is tagged und and the setting selects the national name rather than a named language. --- docs/docs/api/style.md | 5 +++++ docs/docs/limitations.md | 7 +++++++ docs/docs/rendering.md | 2 +- docs/docs/tile-schema.md | 3 ++- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/docs/api/style.md b/docs/docs/api/style.md index 159d67be..15584ec1 100644 --- a/docs/docs/api/style.md +++ b/docs/docs/api/style.md @@ -52,6 +52,11 @@ 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 national_names; /* label a feature with its national-language name + * (NOBJNM) where the cell has one. The bake stores + * that name beside the portrayed one, so this + * switches without a re-bake. A feature with only + * NOBJNM is labelled with it whatever this says. */ } 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 9a253c14..12064489 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -51,6 +51,13 @@ 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 + `national_names` setting selects the national name rather than a named + language. 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 diff --git a/docs/docs/rendering.md b/docs/docs/rendering.md index 84c48945..27ab358e 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 --national-names --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 80f2a579..fdcb5829 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 with `NOBJNM` also gets `text_nat`). | Field | Type | Meaning | | --- | --- | --- | | `text` | string | The label text. | +| `text_nat` | string | The same label with the feature's national-language name (`NOBJNM`) in place of `OBJNAM`, present only when the feature has one. A client reading it selects the national name 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). | From 8738bc0a007386d14dbe678af0663aac701c7ebe Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 07/13] Add the two struct fields style.md was missing The reproduced tile57_mariner omitted soundings and chart_over_image, both appended to the header before this branch. The page now lists all 35 fields in header order, checked by extracting the field names from both. --- docs/docs/api/style.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/docs/api/style.md b/docs/docs/api/style.md index 15584ec1..468453f8 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,15 @@ 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. */ bool national_names; /* label a feature with its national-language name * (NOBJNM) where the cell has one. The bake stores * that name beside the portrayed one, so this From 79e5d1f0d9c03a516b6693d64850fe227443047a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 08/13] Select the national name through PreferredLanguage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The twin was built by substituting NOBJNM for OBJNAM in the label, which reads names off the s57.Feature. A native S-101 dataset keeps its names in featureName complexes, and native.zig surrogates only simple attributes onto the shell, so a chart naming its features in Inuktitut portrayed in English and the setting had no effect. PreferredLanguage was fixed at 'eng' in the driver and is now a context parameter. adapter.nationalLanguage reports the first featureName language in the cell that is not English. An S-57 cell states und, because the adapter tags NOBJNM that way. An S-101 dataset states its own ISO 639-2 code. A baked chart runs a fifth portrayal pass in that language, beside the plain, simplified and full-light-lines passes. Only the label text differs, so scene compares the two passes per text instruction and bakes the difference as text_nat. A live render portrays once, so tile57 png, pdf and ascii pass the language into the portrayal context. A native S-101 dataset naming its features in seven languages changes its labels to the Inuktitut entries with the flag, and the embedded Noto Sans draws those as boxes. An S-57 cell with both names still reads Rossett Island and Rössett Inseln. Three S-57 cells bake byte identical to main. --- src/chart.zig | 14 ++++- src/portray/lua_shim.c | 10 ++- src/portray/portray.zig | 23 +++++++ src/render/ascii_view_test.zig | 12 ++-- src/s101/adapter.zig | 51 ++++++++++++++- src/scene/bake_enc.zig | 3 +- src/scene/scene.zig | 110 +++++++++++++++++---------------- tools/render.zig | 28 ++++++++- 8 files changed, 187 insertions(+), 64 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index d738e7a7..fb1a2216 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -229,6 +229,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 ?[]const u8 = null, // PreferredLanguage variant (national names) 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) @@ -266,6 +267,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, @@ -289,6 +291,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 ?[]const u8 = null, 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 @@ -523,6 +526,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 = cp.national; lc.arena = p; } else |_| { p.deinit(); @@ -552,6 +556,7 @@ fn lazyUnload(lc: *LazyCell) void { lc.portrayal_plain = null; lc.portrayal_simplified = null; lc.portrayal_lights = null; + lc.portrayal_national = null; if (lc.arena) |p| { p.deinit(); gpa.destroy(p); @@ -693,6 +698,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 = 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 @@ -2844,6 +2850,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; }, @@ -3106,6 +3113,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, @@ -3268,6 +3276,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; }, @@ -3321,6 +3330,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; }, @@ -3896,6 +3906,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 ?[]const u8 = null; var geo: ?scene.GeoParts = null; var geo_world: ?scene.GeoWorld = null; var feat_bbox: ?[]const ?[4]f64 = null; @@ -3907,6 +3918,7 @@ const BakeWork = struct { portrayal_plain = cp.plain; portrayal_simplified = cp.simplified; portrayal_lights = cp.lights; + portrayal_national = cp.national; } else |_| {} // Build the geometry cache for EVERY cell, unconditionally. // `build_geo` (cacheGeoForBand) gated it to the finer bands, but coarse cells are @@ -3945,7 +3957,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 97b0128e..f8835d07 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 8f1c78b4..da61f52d 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,11 @@ 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, + /// A pass with PreferredLanguage set to the chart's national language, so + /// GetFeatureName returns the national featureName where a feature has one. + /// Null when every name in the cell is English. Only the label text differs + /// from `base`, which is what scene bakes as the text_nat twin. + national: ?[]const ?[]const u8 = null, }; /// Portray a cell three ways so the client can toggle boundary style (areas) and @@ -470,5 +481,17 @@ 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; + if (adapter.nationalLanguage(adapted)) |lang| { + // The catalogue compares featureName.language against this, so the + // rules pick the national name and every other instruction the pass + // emits stays as it was. + var buf: [16]u8 = undefined; + if (lang.len < buf.len) { + @memcpy(buf[0..lang.len], lang); + buf[lang.len] = 0; + const z: [:0]const u8 = buf[0..lang.len :0]; + cp.national = runAdapted(arena, cell, adapted, rules_dir, .{ .preferred_language = z }) catch null; + } + } return cp; } diff --git a/src/render/ascii_view_test.zig b/src/render/ascii_view_test.zig index 7abd5100..22a94a2d 100644 --- a/src/render/ascii_view_test.zig +++ b/src/render/ascii_view_test.zig @@ -174,24 +174,26 @@ test "ascii view: the national name replaces the portrayed one when asked" { geo[0] = parts; portray.setQuiet(true); - const streams = try portray.portrayCell(a, &cell, ""); + // 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: []const ?[]const u8, g: []?[][]s57.LonLat, col: *render.resolve.Colors, m: *const render.resolve.Settings, b: [4]f64) ![]const u8 { + 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 cells = [_]scene.CellRef{.{ .cell = c, .portrayal = st, .geo = g }}; + const cells = [_]scene.CellRef{.{ .cell = c, .portrayal = st.base, .portrayal_national = st.national, .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, streams, geo, &colors, &off, tb); + 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{ .national_names = true }; - const national = try draw(a, &cell, streams, geo, &colors, &on, tb); + 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/s101/adapter.zig b/src/s101/adapter.zig index d03668b0..2e884d3e 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,23 @@ fn buildSurveyDateRange(a: std.mem.Allocator, children: *std.ArrayList(ChildEntr } } +/// The chart's national language: the first featureName language across the +/// adapted features that is not English. An S-57 cell has one national name per +/// feature and the adapter tags it `und`; a native S-101 dataset states real +/// ISO 639-2 codes. Null when every name is English, which is when a national +/// portrayal pass has nothing to select. +pub fn nationalLanguage(adapted: []const Adapted) ?[]const u8 { + 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")) return lang; + } + } + return null; +} + /// 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; @@ -2260,3 +2277,35 @@ test "a repeated name attribute reads the same way the surface reads it" { try std.testing.expectEqualStrings("First", feats[0].attr(s57.ATTR_OBJNAM).?); try std.testing.expectEqualStrings("Eerste", feats[0].attr(s57.ATTR_NOBJNM).?); } + +test "nationalLanguage finds the non-English featureName language" { + 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); + try std.testing.expectEqualStrings("und", nationalLanguage(adapted).?); + + // 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.expect(nationalLanguage(a2) == null); +} diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig index 35795392..de537f02 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 ?[]const u8 = null, // PreferredLanguage variant (national names) 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/scene.zig b/src/scene/scene.zig index 5a0c24aa..b9296b27 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -1248,12 +1248,15 @@ 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. -/// The national twin of `label` for `f`, or "" when the feature has no NOBJNM -/// and when the label does not hold the feature's OBJNAM. -fn nationalLabel(a: Allocator, label: []const u8, f: s57.Feature) ![]const u8 { - const name = f.attr(s57.ATTR_OBJNAM) orelse return ""; - const nat = f.attr(s57.ATTR_NOBJNM) orelse return ""; - return (try rs.nationalName(a, label, name, nat)) orelse ""; +/// The national-language twin of text instruction `ti`, or "" when the +/// national pass produced the same string. The two passes run the same rules +/// over the same features, so the instruction at index `ti` is the same +/// instruction with a different name selected. +fn nationalText(a: Allocator, class: []const u8, nat_texts: []const instructions.Text, ti: usize, base_text: []const u8) ![]const u8 { + if (ti >= nat_texts.len) return ""; + const nt = nat_texts[ti].text; + if (std.mem.eql(u8, nt, base_text)) return ""; + return expandSeabedText(a, class, stripNameTag(nt)); } fn appendTextProps(a: Allocator, props: *std.ArrayList(mvt.Prop), text: []const u8, text_style: *const rs.TextStyle) !void { @@ -1703,32 +1706,38 @@ 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 u8, 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); + // The national pass differs from the base only in the label text, so it + // travels as an alternative text list rather than a second drawn pass. + const nat_texts: []const instructions.Text = if (variantDiffers(instr, national)) + (try instructions.parse(a, national.?)).texts + else + &.{}; 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 @@ -1857,7 +1866,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 instructions.Text, 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{ @@ -1919,10 +1928,10 @@ 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| { + 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 nationalLabel(a, label, f); + ts.national = try nationalText(a, fmeta.class, nat_texts, ti, t.text); try surf.beginFeature(&fmeta); try surf.drawText(label, &ts, pt); try surf.endFeature(); @@ -2051,10 +2060,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| { + 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 nationalLabel(a, body, f); + ts.national = try nationalText(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); @@ -2368,6 +2377,9 @@ pub const CellRef = struct { portrayal_plain: ?[]const ?[]const u8 = null, portrayal_simplified: ?[]const ?[]const u8 = null, portrayal_lights: ?[]const ?[]const u8 = null, + /// PreferredLanguage pass: the same instructions with the national + /// featureName selected. Only the label text differs from `portrayal`. + portrayal_national: ?[]const ?[]const u8 = null, geo: ?GeoParts = null, /// World coords parallel to `geo` (precomputed projection) — lets the baker /// reproject line/area geometry per tile without per-point tan/log. @@ -2448,7 +2460,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); @@ -2509,7 +2521,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); } } @@ -2688,6 +2700,7 @@ fn appendCellFeatures( portrayal_plain: ?[]const ?[]const u8, portrayal_simplified: ?[]const ?[]const u8, portrayal_lights: ?[]const ?[]const u8, + portrayal_national: ?[]const ?[]const u8, geo: ?GeoParts, geo_world: ?GeoWorld, feat_bbox: ?[]const ?[4]f64, @@ -2814,7 +2827,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; } } @@ -2825,7 +2838,8 @@ 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); + const national: ?[]const u8 = if (portrayal_national) |pp| (if (fi < pp.len) pp[fi] else null) else null; + try processFeatureInstr(a, cell.*, f, fi, geo, geo_world, s, plain, simplified, lights, national, z, x, y, tb, box, fopts, surf); continue; } } @@ -3085,7 +3099,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, 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); @@ -3099,7 +3113,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, 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")); @@ -3153,7 +3167,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, 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); @@ -3202,7 +3216,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, 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); @@ -3384,7 +3398,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, 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); @@ -3398,7 +3412,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, 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")); @@ -3412,7 +3426,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, 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")); } @@ -3507,30 +3521,22 @@ test "augmentV3: vz/ep/lt/iso/mq/lsk precomputes" { try std.testing.expectEqual(@as(i64, 1), findP(lns[0].properties, "mq").?.int); } -test "nationalLabel substitutes the national name inside the label" { +test "nationalText reports only a label the national pass changed" { 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 = "Shanghai" }, - .{ .code = s57.ATTR_NOBJNM, .value = "\u{4e0a}\u{6d77}" }, + const nat = [_]instructions.Text{ + .{ .text = "\u{4e0a}\u{6d77}", .color = "CHBLK", .font_size = 10 }, + .{ .text = "Fl G 4s", .color = "CHBLK", .font_size = 10 }, }; - const f = s57.Feature{ .rcnm = 100, .rcid = 1, .prim = 3, .objl = 13, .attrs = &both }; - - try std.testing.expectEqualStrings("\u{4e0a}\u{6d77}", try nationalLabel(a, "Shanghai", f)); - // A rule that wraps the name keeps its wrapper (AnchorBerth uses "Nr %s"). - try std.testing.expectEqualStrings("Nr \u{4e0a}\u{6d77}", try nationalLabel(a, "Nr Shanghai", f)); - // A label that is not this feature's name has no twin. - try std.testing.expectEqualStrings("", try nationalLabel(a, "Fl G 4s", f)); - - const eng_only = [_]s57.Attr{.{ .code = s57.ATTR_OBJNAM, .value = "Boston" }}; - const fe = s57.Feature{ .rcnm = 100, .rcid = 2, .prim = 3, .objl = 13, .attrs = &eng_only }; - try std.testing.expectEqualStrings("", try nationalLabel(a, "Boston", fe)); - - // National name alone: the portrayed label already is it, because the - // adapter gives that entry nameUsage 1. - const nat_only = [_]s57.Attr{.{ .code = s57.ATTR_NOBJNM, .value = "\u{65e5}\u{672c}" }}; - const fn_ = s57.Feature{ .rcnm = 100, .rcid = 3, .prim = 3, .objl = 13, .attrs = &nat_only }; - try std.testing.expectEqualStrings("", try nationalLabel(a, "\u{65e5}\u{672c}", fn_)); + + // The rules picked a different name for instruction 0. + try std.testing.expectEqualStrings("\u{4e0a}\u{6d77}", try nationalText(a, "BUAARE", &nat, 0, "Shanghai")); + // Instruction 1 is the same string in both passes, so it has no twin. + try std.testing.expectEqualStrings("", try nationalText(a, "LIGHTS", &nat, 1, "Fl G 4s")); + // A feature the national pass did not cover. + try std.testing.expectEqualStrings("", try nationalText(a, "BUAARE", &.{}, 0, "Shanghai")); + // An index past the national pass's texts. + try std.testing.expectEqualStrings("", try nationalText(a, "BUAARE", &nat, 5, "Shanghai")); } diff --git a/tools/render.zig b/tools/render.zig index 9714c7c7..d66e066d 100644 --- a/tools/render.zig +++ b/tools/render.zig @@ -38,6 +38,17 @@ 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 national-name render portrays in: the chart's own national +/// language, or English when every name is English. Written into `buf` because +/// the portrayal context takes a NUL-terminated string. +fn preferredLanguage(adapted: []const engine.s101.adapter.Adapted, buf: *[16]u8) [:0]const u8 { + const lang = engine.s101.adapter.nationalLanguage(adapted) orelse return "eng"; + if (lang.len >= buf.len) return "eng"; + @memcpy(buf[0..lang.len], lang); + buf[lang.len] = 0; + return buf[0..lang.len :0]; +} + 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" ++ @@ -195,13 +206,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.national_names) lctx.preferred_language = preferredLanguage(loaded.adapted, &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.national_names) { + const ad = try engine.s101.adapter.adaptCell(a, &cell); + lctx.preferred_language = preferredLanguage(ad, &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(); From 5670da68e90fe3910842f074c9cff73965b86c73 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 09/13] Frame a UCS-2 attribute field 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 in an ATTF or NATF field. parseAttrs split on the single byte, so it resumed one byte early and read every ATTL after the first from the wrong offset. Latin text in UCS-2 is all bytes under 0x80 and took the ASCII fast path, which frames it the same wrong way. DSSI states the level in NALL. A Chinese cell has been reported stating NALL 0 while writing UTF-16LE, so isDoubleByteField reads the encoding from the field itself: every value an even number of bytes, and at least one terminator followed by a NUL. A single-byte field matches only if every attribute code in it is a multiple of 256 and every value has even length. ucs2ToUtf8 decodes the values. An unpaired surrogate reads as U+FFFD rather than failing the cell. Tested against synthesized level 2 fields, CJK and Latin, and against a cell with a Latin-1 NATF, whose baked tile is unchanged. Three S-57 cells bake byte identical to main. --- src/s57/s57.zig | 115 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/src/s57/s57.zig b/src/s57/s57.zig index 2bd54ace..1330422c 100644 --- a/src/s57/s57.zig +++ b/src/s57/s57.zig @@ -1185,6 +1185,57 @@ 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) { + 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 / @@ -1196,6 +1247,22 @@ 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) { + 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; @@ -2588,3 +2655,51 @@ test { _ = iso8211; _ = decode; } + +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); +} From 7afa20e1960148de9891940069a8dfc3d3114874 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 10/13] Record the glyph coverage limit for a national name The pixel and PDF outputs draw labels with the bundled Noto Sans, which covers Latin, Greek and Cyrillic. A national name in another script draws as boxes. A chart naming its features in Inuktitut syllabics does this. The conversion entry had a semicolon joining two clauses. --- docs/docs/limitations.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 12064489..6a0a138e 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -57,7 +57,7 @@ result is **best effort**: second `featureName` tagged ISO 639-2 `und`, undetermined, so the `national_names` setting selects the national name rather than a named language. S-101 permits several `featureName` entries with distinct - languages; an S-57 source yields at most one. + 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 @@ -102,6 +102,12 @@ result is **best effort**: ## Display / style gaps +- **The embedded font covers Latin, Greek and Cyrillic.** `national_names` + selects a feature's national `featureName`, and the pixel and PDF outputs draw + it with the bundled Noto Sans. A name in a script the face has no glyphs for + draws as boxes. A chart naming its features in Inuktitut syllabics does this. + A host that supplies its own face through the surface callbacks is unaffected. + - **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 From c6a4d8df3db7ff5a07f0439211bd2a25da7f335a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 11/13] Indent the national stream field in cellRef zig fmt --check rejected src/chart.zig. The CellRef literal at the compose-tile call site had the portrayal_national field indented one level too deep. --- src/chart.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chart.zig b/src/chart.zig index fb1a2216..d79853b5 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -3276,7 +3276,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, + .portrayal_national = cb2.portrayal_national, }}; scene.appendTile(surf, a, &one, z, qt.tx, qt.ty, self.pick_attrs) catch continue; }, From b7a362c7e05b519f0a888258f2cc30f862f250a7 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 15:35:02 -0400 Subject: [PATCH 12/13] Stop a UCS-2 attribute field at its two-byte terminator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isDoubleByteField and the UCS-2 split ran past the end of the field. At lexical level 2 the field terminator is the two-byte code unit 0x001E, and iso8211.parseFields strips a single-byte FT only, so 1E 00 is still in the field data. The scan read those two bytes as another ATTL, found no unit terminator after them, and reported the field as single byte. Both loops now stop at the FT. A Chinese cell stating NALL 0 while writing UTF-16LE reads its NOBJNM values as 富民沙路 and 合心, beside OBJNAM Fuminsha Lu and Hexin. The test uses the byte sequence such a field has. --- src/s57/s57.zig | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/s57/s57.zig b/src/s57/s57.zig index 1330422c..1d38d729 100644 --- a/src/s57/s57.zig +++ b/src/s57/s57.zig @@ -1203,6 +1203,9 @@ fn isDoubleByteField(data: []const u8) bool { 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; @@ -1253,6 +1256,7 @@ fn parseAttrs(a: Allocator, data: []const u8, keep_del: bool) ![]Attr { 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; @@ -2703,3 +2707,18 @@ test "a single-byte attribute field is left alone" { 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); +} From d5194a20b161373e037852d6b867f0acd715f68e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 4 Sep 2026 16:00:43 -0400 Subject: [PATCH 13/13] Select a label language, and draw scripts the bundled face lacks national_names was a boolean, so one alternative language was reachable per cell, chosen as the first non-English one the chart stated. A chart naming its features in several languages offered the rest to nobody. tile57_mariner.preferred_language is an ISO 639-2 code. adapter.languages reports the languages a chart states, capped at four, and the bake runs a portrayal pass per language, storing each label as text_. A style coalesces the mariner's code, then text_und, then the portrayed name. TextStyle.national holds the set, so a surface picks the same way whether the scene fills it or replay reads it back from a tile. An S-57 cell states und, because S-57 records no language for NOBJNM, and und answers any code the mariner asks for. The pixel outputs draw every label with the bundled Noto Sans, which covers Latin, Greek and Cyrillic, so a label in another script came out as boxes. font.Font reads a TrueType collection, because the CJK faces a system ships arrive that way, and font.fallback holds a face consulted for a codepoint the run's face has no glyph for. pushText picks a face per codepoint. The engine holds no path, so a host installs the face with setFallback and the render tools read TILE57_FONT_FALLBACK. The PDF and vector paths embed one face per label and still draw the bundled face there. The png, pdf and ascii tools take --language in place of --national-names. Three S-57 cells bake byte identical to main. --- docs/docs/api/style.md | 12 +-- docs/docs/limitations.md | 19 +++-- docs/docs/rendering.md | 2 +- docs/docs/tile-schema.md | 4 +- include/tile57.h | 16 ++-- src/capi.zig | 22 ++++-- src/chart.zig | 22 ++++-- src/portray/portray.zig | 36 ++++++--- src/render/ascii.zig | 11 +-- src/render/ascii_view_test.zig | 12 +-- src/render/font.zig | 34 +++++++- src/render/pixel.zig | 40 +++++++--- src/render/surface.zig | 47 +++++------ src/s101/adapter.zig | 37 ++++++--- src/scene/bake_enc.zig | 2 +- src/scene/replay.zig | 22 +++++- src/scene/scene.zig | 140 ++++++++++++++++++++++----------- src/style/maplibre.zig | 23 +++--- src/style/mariner.zig | 22 ++++-- tools/ascii.zig | 18 ++++- tools/render.zig | 48 +++++++---- 21 files changed, 401 insertions(+), 188 deletions(-) diff --git a/docs/docs/api/style.md b/docs/docs/api/style.md index 468453f8..46fd00f2 100644 --- a/docs/docs/api/style.md +++ b/docs/docs/api/style.md @@ -67,11 +67,13 @@ typedef struct tile57_mariner { * 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. */ - bool national_names; /* label a feature with its national-language name - * (NOBJNM) where the cell has one. The bake stores - * that name beside the portrayed one, so this - * switches without a re-bake. A feature with only - * NOBJNM is labelled with it whatever this says. */ + 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 6a0a138e..98375225 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -55,8 +55,7 @@ result is **best effort**: 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 - `national_names` setting selects the national name rather than a named - language. S-101 permits several `featureName` entries with distinct + 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 @@ -102,11 +101,17 @@ result is **best effort**: ## Display / style gaps -- **The embedded font covers Latin, Greek and Cyrillic.** `national_names` - selects a feature's national `featureName`, and the pixel and PDF outputs draw - it with the bundled Noto Sans. A name in a script the face has no glyphs for - draws as boxes. A chart naming its features in Inuktitut syllabics does this. - A host that supplies its own face through the surface callbacks is unaffected. +- **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 diff --git a/docs/docs/rendering.md b/docs/docs/rendering.md index 27ab358e..7b3dd8fc 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 --national-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 fdcb5829..42e0cba6 100644 --- a/docs/docs/tile-schema.md +++ b/docs/docs/tile-schema.md @@ -146,12 +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; a feature with `NOBJNM` also gets `text_nat`). +attribute; a feature named in other languages also gets a `text_` for each). | Field | Type | Meaning | | --- | --- | --- | | `text` | string | The label text. | -| `text_nat` | string | The same label with the feature's national-language name (`NOBJNM`) in place of `OBJNAM`, present only when the feature has one. A client reading it selects the national name without a re-bake. | +| `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 1c5c7d2d..e79d4d46 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -652,13 +652,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. */ - bool national_names; /* Label a feature with its national-language name - * (NOBJNM) where the cell carries one. The bake stores - * that name beside the portrayed one, so this switches - * without a re-bake. A feature with only NOBJNM is - * labelled with it whatever this says. - * Appended for ABI-append-safety; 0 keeps the portrayed - * name. */ + 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 28e92fd8..f583dc6b 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -2203,6 +2203,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, @@ -2264,11 +2272,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, - // Label a feature with its national-language name (NOBJNM) where the cell - // carries one. The bake stores that name beside the portrayed one as - // `text_nat`, so this switches without a re-bake. Appended for - // ABI-append-safety; a zeroed struct keeps the portrayed name. - national_names: 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. @@ -2331,7 +2339,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, - .national_names = cm.national_names, + .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 @@ -2516,7 +2524,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, - .national_names = d.national_names, + .preferred_language = langCode(d.preferred_language), }; } diff --git a/src/chart.zig b/src/chart.zig index d79853b5..d8930151 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -229,7 +229,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 ?[]const u8 = null, // PreferredLanguage variant (national names) + 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) @@ -291,7 +291,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 ?[]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 @@ -503,6 +503,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); @@ -526,7 +534,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 = cp.national; + lc.portrayal_national = nationalPasses(p.allocator(), cp.national); lc.arena = p; } else |_| { p.deinit(); @@ -556,7 +564,7 @@ fn lazyUnload(lc: *LazyCell) void { lc.portrayal_plain = null; lc.portrayal_simplified = null; lc.portrayal_lights = null; - lc.portrayal_national = null; + lc.portrayal_national = &.{}; if (lc.arena) |p| { p.deinit(); gpa.destroy(p); @@ -698,7 +706,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 = cp.national; + 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 @@ -3906,7 +3914,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 ?[]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; @@ -3918,7 +3926,7 @@ const BakeWork = struct { portrayal_plain = cp.plain; portrayal_simplified = cp.simplified; portrayal_lights = cp.lights; - portrayal_national = cp.national; + 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 diff --git a/src/portray/portray.zig b/src/portray/portray.zig index da61f52d..49631d0f 100644 --- a/src/portray/portray.zig +++ b/src/portray/portray.zig @@ -432,11 +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, - /// A pass with PreferredLanguage set to the chart's national language, so - /// GetFeatureName returns the national featureName where a feature has one. - /// Null when every name in the cell is English. Only the label text differs - /// from `base`, which is what scene bakes as the text_nat twin. - national: ?[]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 @@ -481,17 +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; - if (adapter.nationalLanguage(adapted)) |lang| { - // The catalogue compares featureName.language against this, so the - // rules pick the national name and every other instruction the pass - // emits stays as it was. - var buf: [16]u8 = undefined; - if (lang.len < buf.len) { + // 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]; - cp.national = runAdapted(arena, cell, adapted, rules_dir, .{ .preferred_language = z }) catch null; + 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 8b42276d..0240f42a 100644 --- a/src/render/ascii.zig +++ b/src/render/ascii.zig @@ -342,13 +342,10 @@ pub const AsciiSurface = struct { const self = sp(ctx); if (!self.cur_visible) return; if (!resolve.textGroupVisible(style.group, self.settings)) return; - // The national-language label, when the mariner selected it. The scene - // fills style.national and replay reads it back from the tile, so both - // paths arrive here the same way. - const shown = if (self.settings.national_names and style.national.len > 0) - style.national - else - text; + // 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 = shown[0 .. std.mem.indexOfScalar(u8, shown, ' ') orelse shown.len]; if (word.len == 0) return; diff --git a/src/render/ascii_view_test.zig b/src/render/ascii_view_test.zig index 22a94a2d..f5596904 100644 --- a/src/render/ascii_view_test.zig +++ b/src/render/ascii_view_test.zig @@ -127,9 +127,9 @@ test "ascii view: water shades left, land '#' right, coastline between" { } test "ascii view: the national name replaces the portrayed one when asked" { - // The tile path bakes text_nat for a style to coalesce. A character - // surface substitutes at draw time instead. This drives the real rules and - // reads the label off the grid. + // 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(); @@ -182,7 +182,9 @@ test "ascii view: the national name replaces the portrayed one when asked" { 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 cells = [_]scene.CellRef{.{ .cell = c, .portrayal = st.base, .portrayal_national = st.national, .geo = g }}; + 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; @@ -192,7 +194,7 @@ test "ascii view: the national name replaces the portrayed one when asked" { 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{ .national_names = true }; + 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 f93f94f8..bba589d6 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 77951e5f..3b04cc1b 100644 --- a/src/render/pixel.zig +++ b/src/render/pixel.zig @@ -501,13 +501,10 @@ pub const PixelSurface = struct { const self = sp(ctx); if (!self.cur_visible) return; if (!resolve.textGroupVisible(style.group, self.settings)) return; - // The national-language label, when the mariner selected it. The scene - // fills style.national and replay reads it back from the tile, so both - // paths arrive here the same way. - const shown = if (self.settings.national_names and style.national.len > 0) - style.national - else - text; + // 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 @@ -528,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 }; @@ -553,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; @@ -578,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 7f4d88d0..e7f53bb5 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,11 +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) - /// The national-language twin of this label ("" when the feature has no - /// NOBJNM). The scene fills it, a tile bakes it as `text_nat`, and replay - /// reads it back, so both paths hand a surface the same pair. This mirrors - /// the depth twin, where the raw metres go in `drawDepthText`. - national: []const u8 = "", + /// 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 @@ -85,23 +92,19 @@ pub const TextStyle = struct { /// surfaces need not import s57/s101. pub const BAND_UNKNOWN: u8 = 255; -/// The national-language twin of a label: NOBJNM substituted for the OBJNAM -/// occurrence inside the portrayed string. A rule can wrap a name, as -/// AnchorBerth does with "Nr %s", so substituting keeps the wrapper. -/// -/// Returns null for a feature with no national name, for one with no OBJNAM -/// (the portrayed label is already the national name, because the adapter -/// gives that entry nameUsage 1), and for a label that does not contain the -/// name. Every text instruction other than this feature's name takes the last -/// case. -pub fn nationalName(a: std.mem.Allocator, text: []const u8, name: []const u8, name_nat: []const u8) !?[]const u8 { - if (name_nat.len == 0 or name.len == 0) return null; - const at = std.mem.indexOf(u8, text, name) orelse return null; - const out = try a.alloc(u8, text.len - name.len + name_nat.len); - @memcpy(out[0..at], text[0..at]); - @memcpy(out[at..][0..name_nat.len], name_nat); - @memcpy(out[at + name_nat.len ..], text[at + name.len ..]); - return out; +/// 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 { diff --git a/src/s101/adapter.zig b/src/s101/adapter.zig index 2e884d3e..a4b85f0b 100644 --- a/src/s101/adapter.zig +++ b/src/s101/adapter.zig @@ -778,21 +778,34 @@ fn buildSurveyDateRange(a: std.mem.Allocator, children: *std.ArrayList(ChildEntr } } -/// The chart's national language: the first featureName language across the -/// adapted features that is not English. An S-57 cell has one national name per -/// feature and the adapter tags it `und`; a native S-101 dataset states real -/// ISO 639-2 codes. Null when every name is English, which is when a national -/// portrayal pass has nothing to select. -pub fn nationalLanguage(adapted: []const Adapted) ?[]const u8 { +/// 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")) return lang; + 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 null; + return out.items; } /// Adapt all mappable features of a cell. Allocates into `a` (use an arena). @@ -2278,7 +2291,7 @@ test "a repeated name attribute reads the same way the surface reads it" { try std.testing.expectEqualStrings("Eerste", feats[0].attr(s57.ATTR_NOBJNM).?); } -test "nationalLanguage finds the non-English featureName language" { +test "languages lists the non-English featureName languages" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); @@ -2300,12 +2313,14 @@ test "nationalLanguage finds the non-English featureName language" { defer cell.arena.deinit(); const adapted = try adaptCell(a, &cell); - try std.testing.expectEqualStrings("und", nationalLanguage(adapted).?); + 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.expect(nationalLanguage(a2) == null); + try std.testing.expectEqual(@as(usize, 0), (try languages(a, a2)).len); } diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig index de537f02..6742fb58 100644 --- a/src/scene/bake_enc.zig +++ b/src/scene/bake_enc.zig @@ -34,7 +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 ?[]const u8 = null, // PreferredLanguage variant (national names) + 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 diff --git a/src/scene/replay.zig b/src/scene/replay.zig index ca0bbf9e..743ac2d5 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,10 +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), - // The national twin the bake stored beside the label, so a - // surface reading a bundle sees the same pair the scene + // 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 = propStr(f.properties, "text_nat"), + .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 b9296b27..a49da284 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -787,8 +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 - if (text_style.national.len > 0) - try props.append(s.a, .{ .key = "text_nat", .value = .{ .string = text_style.national } }); + // 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); @@ -1248,17 +1252,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. -/// The national-language twin of text instruction `ti`, or "" when the -/// national pass produced the same string. The two passes run the same rules -/// over the same features, so the instruction at index `ti` is the same -/// instruction with a different name selected. -fn nationalText(a: Allocator, class: []const u8, nat_texts: []const instructions.Text, ti: usize, base_text: []const u8) ![]const u8 { - if (ti >= nat_texts.len) return ""; - const nt = nat_texts[ti].text; - if (std.mem.eql(u8, nt, base_text)) return ""; - return expandSeabedText(a, class, stripNameTag(nt)); +/// 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. @@ -1706,14 +1720,16 @@ 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, national: ?[]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); - // The national pass differs from the base only in the label text, so it - // travels as an alternative text list rather than a second drawn pass. - const nat_texts: []const instructions.Text = if (variantDiffers(instr, national)) - (try instructions.parse(a, national.?)).texts - else - &.{}; + // 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 @@ -1866,7 +1882,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, nat_texts: []const instructions.Text, 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{ @@ -1931,7 +1947,7 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, 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 nationalText(a, fmeta.class, nat_texts, ti, t.text); + ts.national = try nationalTexts(a, fmeta.class, nat_texts, ti, t.text); try surf.beginFeature(&fmeta); try surf.drawText(label, &ts, pt); try surf.endFeature(); @@ -2063,7 +2079,7 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, 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 nationalText(a, fmeta.class, nat_texts, ti, 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); @@ -2371,15 +2387,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, - /// PreferredLanguage pass: the same instructions with the national - /// featureName selected. Only the label text differs from `portrayal`. - portrayal_national: ?[]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. @@ -2700,7 +2729,7 @@ fn appendCellFeatures( portrayal_plain: ?[]const ?[]const u8, portrayal_simplified: ?[]const ?[]const u8, portrayal_lights: ?[]const ?[]const u8, - portrayal_national: ?[]const ?[]const u8, + portrayal_national: []const LangStreams, geo: ?GeoParts, geo_world: ?GeoWorld, feat_bbox: ?[]const ?[4]f64, @@ -2838,7 +2867,11 @@ 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; - const national: ?[]const u8 = if (portrayal_national) |pp| (if (fi < pp.len) pp[fi] else null) else null; + 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; } @@ -3099,7 +3132,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, 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); @@ -3113,7 +3146,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, 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")); @@ -3167,7 +3200,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, 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); @@ -3216,7 +3249,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, 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); @@ -3398,7 +3431,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, 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); @@ -3412,7 +3445,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, 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")); @@ -3426,7 +3459,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, 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")); } @@ -3521,22 +3554,41 @@ test "augmentV3: vz/ep/lt/iso/mq/lsk precomputes" { try std.testing.expectEqual(@as(i64, 1), findP(lns[0].properties, "mq").?.int); } -test "nationalText reports only a label the national pass changed" { +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 nat = [_]instructions.Text{ + const zho = [_]instructions.Text{ .{ .text = "\u{4e0a}\u{6d77}", .color = "CHBLK", .font_size = 10 }, .{ .text = "Fl G 4s", .color = "CHBLK", .font_size = 10 }, }; - - // The rules picked a different name for instruction 0. - try std.testing.expectEqualStrings("\u{4e0a}\u{6d77}", try nationalText(a, "BUAARE", &nat, 0, "Shanghai")); - // Instruction 1 is the same string in both passes, so it has no twin. - try std.testing.expectEqualStrings("", try nationalText(a, "LIGHTS", &nat, 1, "Fl G 4s")); - // A feature the national pass did not cover. - try std.testing.expectEqualStrings("", try nationalText(a, "BUAARE", &.{}, 0, "Shanghai")); - // An index past the national pass's texts. - try std.testing.expectEqualStrings("", try nationalText(a, "BUAARE", &nat, 5, "Shanghai")); + 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 7c21652f..c4aedee7 100644 --- a/src/style/maplibre.zig +++ b/src/style/maplibre.zig @@ -1711,23 +1711,26 @@ 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: national names read the NOBJNM twin" { +test "buildFromTemplate: a language preference reads that language's twin" { const a = std.testing.allocator; - const nat = try buildFromTemplate(a, cs_template, &.{ .national_names = true }, cs_ct, null, 1700000000); - defer a.free(nat); - try std.testing.expect(std.mem.indexOf(u8, nat, "text_nat") != null); + 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); - // Off reads the string the rule composed, with no national twin in the style. + // 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_nat") == null); + try std.testing.expect(std.mem.indexOf(u8, off, "text_zho") == null); + try std.testing.expect(std.mem.indexOf(u8, off, "text_und") == null); - // Both settings coalesce, national first. - const both = try buildFromTemplate(a, cs_template, &.{ .national_names = true, .depth_unit = .feet }, cs_ct, null, 1700000000); + // 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 inat = std.mem.indexOf(u8, both, "text_nat").?; + const izho = std.mem.indexOf(u8, both, "text_zho").?; const ift = std.mem.indexOf(u8, both, "text_ft").?; - try std.testing.expect(inat < ift); + try std.testing.expect(izho < ift); } test "buildFromTemplate: enabled bands add a band filter" { diff --git a/src/style/mariner.zig b/src/style/mariner.zig index 942a4103..0d8df202 100644 --- a/src/style/mariner.zig +++ b/src/style/mariner.zig @@ -98,10 +98,14 @@ pub const Settings = struct { text_names: bool = true, show_light_descriptions: bool = true, text_other: bool = true, - /// Label a feature with its national-language name (NOBJNM) where the cell - /// carries one. The bake stores that name beside the portrayed one, so this + /// 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. - national_names: bool = false, + 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 @@ -454,12 +458,18 @@ pub fn contourLabelField(b: B, m: *const Settings) !Value { // 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: [5]Value = undefined; + var terms: [6]Value = undefined; var n: usize = 0; terms[n] = b.s("coalesce"); n += 1; - if (m.national_names) { - terms[n] = try b.get("text_nat"); + 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) { diff --git a/tools/ascii.zig b/tools/ascii.zig index c517aff1..8f50c7da 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,7 +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 national = false; + 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"); @@ -51,8 +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, "--national-names")) { - national = true; + } 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")) { @@ -93,8 +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.national_names = national; + m.preferred_language = language; m.scheme = switch (palette) { .day => .day, .dusk => .dusk, diff --git a/tools/render.zig b/tools/render.zig index d66e066d..2921bc84 100644 --- a/tools/render.zig +++ b/tools/render.zig @@ -38,15 +38,34 @@ 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 national-name render portrays in: the chart's own national -/// language, or English when every name is English. Written into `buf` because -/// the portrayal context takes a NUL-terminated string. -fn preferredLanguage(adapted: []const engine.s101.adapter.Adapted, buf: *[16]u8) [:0]const u8 { - const lang = engine.s101.adapter.nationalLanguage(adapted) orelse return "eng"; - if (lang.len >= buf.len) return "eng"; - @memcpy(buf[0..lang.len], lang); - buf[lang.len] = 0; - return buf[0..lang.len :0]; +/// 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 { @@ -123,8 +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, "--national-names")) { - m.national_names = true; + } 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")) { @@ -192,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. @@ -215,13 +235,13 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: if (engine.s101.dataset.detect(data)) { const loaded = try engine.s101.native.parseDataset(a, data, readUpdates(io, a, path)); cell = loaded.cell; - if (m.national_names) lctx.preferred_language = preferredLanguage(loaded.adapted, &lang_buf); + 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)); - if (m.national_names) { + if (m.preferred_language.len > 0) { const ad = try engine.s101.adapter.adaptCell(a, &cell); - lctx.preferred_language = preferredLanguage(ad, &lang_buf); + 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);