diff --git a/docs/docs/api/render.md b/docs/docs/api/render.md index 9277af3..023e0b6 100644 --- a/docs/docs/api/render.md +++ b/docs/docs/api/render.md @@ -46,6 +46,16 @@ void tile57_chart_get_info(tile57_chart *chart, tile57_info *out); tile57_status tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len, tile57_error *err); +/* The label languages the chart states besides English, as ISO 639-2 codes + * (ascending); NULL/0 when none. A host offers the mariner these: a + * preferred_language outside the set draws the portrayed name, except on an + * S-57 chart, whose one national name is coded "und" and answers any language. + * One allocation holds the pointer array and the codes; free *out with + * tile57_free. The compositor borrows its charts, so a host reading a quilt + * unions the sets from the charts it opened, the way it does for SCAMIN. */ +tile57_status tile57_chart_languages(tile57_chart *chart, const char *const **out, + size_t *out_len, tile57_error *err); + /* The chart's M_COVR data-coverage polygons, from the coverage the bake embedded: * ring() is called once per polygon with its exterior ring as npts interleaved * lon,lat doubles (valid only during the call). OK with no calls when the archive diff --git a/include/tile57.h b/include/tile57.h index e79d4d4..407909c 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -489,6 +489,16 @@ void tile57_chart_get_info(tile57_chart *chart, tile57_info *out); tile57_status tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len, tile57_error *err); +/* The label languages the chart states besides English, as ISO 639-2 codes, + * ascending. A host offers the mariner these and nothing else: a + * tile57_mariner.preferred_language outside the set draws the portrayed name, + * except on an S-57 chart, whose single national name is coded "und" and + * answers any language. TILE57_OK with *out pointing at *out_len + * NUL-terminated codes, or NULL/0 when the chart states none. One allocation + * holds the pointer array and the codes; free *out with tile57_free. */ +tile57_status tile57_chart_languages(tile57_chart *chart, const char *const **out, + size_t *out_len, tile57_error *err); + /* The chart's M_COVR(CATCOV=1) data-coverage polygons, from the coverage the * bake embedded in the archive metadata — the real coverage a host reports so * a quilt fills gaps to coarser charts (vs. the bounding box). ring() is called diff --git a/src/capi.zig b/src/capi.zig index f583dc6..c91033f 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -948,6 +948,40 @@ export fn tile57_chart_scamin(handle: ?*Chart, out: ?*?[*]i32, out_len: ?*usize, return OK; } +/// The label languages the chart states besides English, as NUL-terminated +/// ISO 639-2 codes. A host offers the mariner these, because a +/// `preferred_language` outside the set draws the portrayed name. One +/// allocation holds the pointer array and the codes, so `tile57_free` on +/// `*out` releases all of it. +export fn tile57_chart_languages(handle: ?*Chart, out: ?*?[*]const [*:0]const u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, bad_out); + const n = out_len orelse return failWith(err, .badarg, bad_out); + o.* = null; + n.* = 0; + const s = handle orelse return failWith(err, .badarg, "chart must not be null"); + const codes = s.languages() catch |e| return fail(err, e); + defer { + for (codes) |c| gpa.free(c); + gpa.free(codes); + } + if (codes.len == 0) return OK; + + var bytes: usize = codes.len * @sizeOf([*:0]const u8); + for (codes) |c| bytes += c.len + 1; + const p = exportAlloc(bytes) orelse return failWith(err, .nomem, "out of memory"); + const table: [*][*:0]const u8 = @ptrCast(@alignCast(p)); + var at: usize = codes.len * @sizeOf([*:0]const u8); + for (codes, 0..) |c, i| { + @memcpy(p[at .. at + c.len], c); + p[at + c.len] = 0; + table[i] = @ptrCast(p + at); + at += c.len + 1; + } + o.* = table; + n.* = codes.len; + return OK; +} + const CCoverageCb = extern struct { ctx: ?*anyopaque, ring: *const fn (?*anyopaque, lonlat: [*]const f64, npts: usize) callconv(.c) void, diff --git a/src/chart.zig b/src/chart.zig index d893015..dcb2aaf 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -3518,6 +3518,30 @@ pub const Chart = struct { std.mem.sort(u32, vals, {}, std.sort.asc(u32)); return vals; } + + /// The label languages the chart states besides English, as ISO 639-2 + /// codes, ascending. A host offers the mariner these and nothing else, + /// because `preferred_language` outside the set draws the portrayed name. + /// A baked source reads them from the archive metadata; a cell source + /// takes them from the adapted features. Returns a gpa-owned slice of + /// gpa-owned codes. + pub fn languages(self: *Chart) ![]const []const u8 { + var out = std.ArrayList([]const u8).empty; + switch (self.backend) { + .reader => |*r| languagesFromMetadata(r, &out), + .cell => |*cb| for (cb.portrayal_national) |p| { + const code = gpa.dupe(u8, p.lang) catch continue; + out.append(gpa, code) catch gpa.free(code); + }, + .cells => {}, + } + std.mem.sort([]const u8, out.items, {}, struct { + fn lt(_: void, x: []const u8, y: []const u8) bool { + return std.mem.lessThan(u8, x, y); + } + }.lt); + return out.toOwnedSlice(gpa); + } }; /// Render ONE feature's resolved portrayal onto a solid background — the @@ -3888,6 +3912,47 @@ fn scanScaminArray(json: []const u8, set: *std.AutoHashMap(u32, void)) void { } } +// The archive metadata's `"languages":["…"]` array, the codes the bake spliced +// in. Read the same way scaminFromMetadata reads its ladder: the metadata is +// uncompressed in this engine's archives, and a gzip writer is handled too. +fn languagesFromMetadata(r: *pmtiles.Reader, out: *std.ArrayList([]const u8)) void { + const h = r.header; + if (h.metadata_length == 0) return; + const raw = r.bytes[@intCast(h.metadata_offset)..][0..@intCast(h.metadata_length)]; + var owned: ?[]u8 = null; + defer if (owned) |o| gpa.free(o); + const json: []const u8 = switch (h.internal_compression) { + .none => raw, + .gzip => blk: { + owned = gzip.decompress(gpa, raw) catch return; + break :blk owned.?; + }, + else => return, + }; + scanLanguageArray(json, out); +} + +// Minimal extractor for `"languages":["zho","und"]`, tolerant of whitespace. +// Each code is duped into gpa, so it outlives a decompressed metadata buffer. +fn scanLanguageArray(json: []const u8, out: *std.ArrayList([]const u8)) void { + const ki = std.mem.indexOf(u8, json, "\"languages\"") orelse return; + var i = ki + "\"languages\"".len; + while (i < json.len and json[i] != '[' and json[i] != '}') i += 1; // skip ` : ` + if (i >= json.len or json[i] != '[') return; + i += 1; + while (i < json.len and json[i] != ']') { + while (i < json.len and json[i] != '"' and json[i] != ']') i += 1; + if (i >= json.len or json[i] == ']') break; + i += 1; + const start = i; + while (i < json.len and json[i] != '"') i += 1; + if (i >= json.len) return; + const code = gpa.dupe(u8, json[start..i]) catch return; + out.append(gpa, code) catch gpa.free(code); + i += 1; + } +} + // ---- ENC_ROOT bake ------------------------------------------------------- const BakeSource = struct { base: []const u8, updates: []const []const u8, name: []const u8 = "" }; @@ -4029,6 +4094,14 @@ pub fn bakeArchive( // of truth) while they're alive, before each band frees them. var scamin_set = std.AutoHashMap(u32, void).init(gpa); defer scamin_set.deinit(); + // The label languages every cell in the bake states, for the archive's + // "languages" key. A host reads them to offer the mariner what the chart + // holds. + var langs = std.ArrayList([]const u8).empty; + defer { + for (langs.items) |l| gpa.free(l); + langs.deinit(gpa); + } // The coarsest populated band gets .extend_min (fill down to minzoom — the // live tileRefs coarsest-band fallback); every other populated band defers its @@ -4140,6 +4213,15 @@ pub fn bakeArchive( const q = bake_enc.overscaleGateDenom(be.cscl); if (q > 0) scamin_set.put(@intCast(q), {}) catch {}; } + for (be.portrayal_national) |p| { + var seen = false; + for (langs.items) |l| { + if (std.mem.eql(u8, l, p.lang)) seen = true; + } + if (seen) continue; + const owned = gpa.dupe(u8, p.lang) catch continue; + langs.append(gpa, owned) catch gpa.free(owned); + } // Fold the sector-figure reach for the archive's "light_reach" key // (union bbox, max ground leg) while the backends are alive. if (be.light_bbox) |lb| { @@ -4220,10 +4302,15 @@ pub fn bakeArchive( while (it.next()) |k| try scamin_vals.append(gpa, k.*); std.mem.sort(u32, scamin_vals.items, {}, std.sort.asc(u32)); } + std.mem.sort([]const u8, langs.items, {}, struct { + fn lt(_: void, x: []const u8, y: []const u8) bool { + return std.mem.lessThan(u8, x, y); + } + }.lt); var light_reach_json: ?[]const u8 = null; defer if (light_reach_json) |lj| gpa.free(lj); if (lr_union) |u| light_reach_json = scene.coverage.encodeLightReachJson(gpa, .{ .bbox = u, .range_m = lr_range_m }) catch null; - const meta = try scene.metadataJson(gpa, scamin_vals.items, coverage_json, light_reach_json); + const meta = try scene.metadataJson(gpa, scamin_vals.items, langs.items, coverage_json, light_reach_json); defer gpa.free(meta); return try sw.finishBytes(.{ .metadata_json = meta, @@ -4247,3 +4334,32 @@ fn streamSink(ctx: ?*anyopaque, z: u8, x: u32, y: u32, comp: []const u8) anyerro const sw: *pmtiles.StreamWriter = @ptrCast(@alignCast(ctx.?)); try sw.addCompressed(z, x, y, comp); } + +test "scanLanguageArray reads the codes the bake spliced in" { + var out = std.ArrayList([]const u8).empty; + defer { + for (out.items) |c| gpa.free(c); + out.deinit(gpa); + } + scanLanguageArray("{\"name\":\"chartplotter\",\"languages\":[\"und\",\"zho\"],\"scamin\":[1000]}", &out); + try std.testing.expectEqual(@as(usize, 2), out.items.len); + try std.testing.expectEqualStrings("und", out.items[0]); + try std.testing.expectEqualStrings("zho", out.items[1]); + + // Whitespace, and an archive that states none. + var ws = std.ArrayList([]const u8).empty; + defer { + for (ws.items) |c| gpa.free(c); + ws.deinit(gpa); + } + scanLanguageArray("{ \"languages\" : [ \"fin\" ] }", &ws); + try std.testing.expectEqual(@as(usize, 1), ws.items.len); + try std.testing.expectEqualStrings("fin", ws.items[0]); + + var none = std.ArrayList([]const u8).empty; + defer none.deinit(gpa); + scanLanguageArray("{\"name\":\"chartplotter\",\"scamin\":[1000]}", &none); + try std.testing.expectEqual(@as(usize, 0), none.items.len); + scanLanguageArray("{\"languages\":[]}", &none); + try std.testing.expectEqual(@as(usize, 0), none.items.len); +} diff --git a/src/scene/scene.zig b/src/scene/scene.zig index a49da28..4508869 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -1082,14 +1082,17 @@ pub const VECTOR_LAYERS = mvt.VECTOR_LAYERS; /// the TileJSON, plus a "scamin" array of the distinct SCAMIN denominators present /// (ascending) so the client builds one native-minzoom bucket layer per value at /// load instead of probing tiles. Mirrors the Go pmtiles.Builder.metadata -/// (vector_layers + scamin splice). `scamin` empty -> omit the field. `coverage_json`, +/// (vector_layers + scamin splice). `scamin` empty -> omit the field. +/// `languages` are the label languages the chart states besides English, as +/// ISO 639-2 codes, spliced under a "languages" key so a host reads what a +/// baked chart offers without decoding a tile. Empty -> omit the field. `coverage_json`, /// when non-null, is a per-cell coverage object (see `coverage.encodeJson`) spliced /// under a "coverage" key — a single-cell composite bake carries its own M_COVR there. /// `light_reach_json` (see `coverage.encodeLightReachJson`), when non-null, is the /// cell's sector-figure reach summary spliced under a "light_reach" key so the /// compositor can widen its tile addressing without re-portraying the cell. /// Caller owns the returned bytes (allocated in `a`). -pub fn metadataJson(a: Allocator, scamin: []const u32, coverage_json: ?[]const u8, light_reach_json: ?[]const u8) ![]const u8 { +pub fn metadataJson(a: Allocator, scamin: []const u32, languages: []const []const u8, coverage_json: ?[]const u8, light_reach_json: ?[]const u8) ![]const u8 { var b = std.ArrayList(u8).empty; try b.appendSlice(a, "{\"name\":\"chartplotter\",\"format\":\"pbf\",\"vector_layers\":["); for (VECTOR_LAYERS, 0..) |name, i| { @@ -1108,6 +1111,16 @@ pub fn metadataJson(a: Allocator, scamin: []const u32, coverage_json: ?[]const u } try b.append(a, ']'); } + if (languages.len > 0) { + try b.appendSlice(a, ",\"languages\":["); + for (languages, 0..) |lang, i| { + if (i > 0) try b.append(a, ','); + try b.append(a, '"'); + try b.appendSlice(a, lang); + try b.append(a, '"'); + } + try b.append(a, ']'); + } if (coverage_json) |cj| { try b.appendSlice(a, ",\"coverage\":"); try b.appendSlice(a, cj); @@ -3592,3 +3605,16 @@ test "nationalFor prefers the mariner's language and falls back to und" { const only_zho = [_]rs.NationalText{.{ .lang = "zho", .text = "\u{4e0a}\u{6d77}" }}; try std.testing.expect(rs.nationalFor(&only_zho, "fin") == null); } + +test "metadataJson states the chart's languages" { + const a = std.testing.allocator; + const with = try metadataJson(a, &.{}, &.{ "und", "zho" }, null, null); + defer a.free(with); + try std.testing.expect(std.mem.indexOf(u8, with, "\"languages\":[\"und\",\"zho\"]") != null); + + // A chart naming every feature in English states none, and the key is left + // out rather than written empty. + const without = try metadataJson(a, &.{}, &.{}, null, null); + defer a.free(without); + try std.testing.expect(std.mem.indexOf(u8, without, "languages") == null); +} diff --git a/src/style/mariner.zig b/src/style/mariner.zig index 0d8df20..46941d1 100644 --- a/src/style/mariner.zig +++ b/src/style/mariner.zig @@ -450,12 +450,12 @@ pub fn contourLabelField(b: B, m: *const Settings) !Value { }); } -// 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. +// The label text-field. The bake stores twins beside the portrayed string: +// text_ft, a dredged area's depth in feet, and one text_ per language the +// chart states. 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 +// A feature has at most one of the two kinds, so their relative order in the // coalesce has no effect. pub fn labelTextField(b: B, m: *const Settings) !Value { var terms: [6]Value = undefined;