Skip to content

Render NOBJNM national language names - #64

Open
beetlebugorg wants to merge 13 commits into
mainfrom
feat/nobjnm-name
Open

Render NOBJNM national language names#64
beetlebugorg wants to merge 13 commits into
mainfrom
feat/nobjnm-name

Conversation

@beetlebugorg

@beetlebugorg beetlebugorg commented Sep 4, 2026

Copy link
Copy Markdown
Owner

tile57 rendered OBJNAM names and dropped NOBJNM, so a feature with a national language name showed only its English counterpart, and a feature with only NOBJNM showed no name at all. Closes #63.

The adapter converts NOBJNM into a second featureName, and selection goes through PreferredLanguage, which the driver had fixed at eng. tile57_mariner.preferred_language is now an ISO 639-2 code. An S-57 cell states und, because S-57 records no language for NOBJNM, and und answers whatever code the mariner asks for. A native S-101 dataset states real codes, so asking for one picks that language's name.

Portrayal runs at bake time, so a baked chart runs a pass per language beside the plain, simplified and full-light-lines passes. Only the label text differs, so the scene stores what changed as text_<lang> and TextStyle.national holds the set for a surface, whether the scene fills it or replay reads it back. depthTextField becomes labelTextField and coalesces the mariner's code, then text_und, then the portrayed name. A live render portrays once and passes the language into the portrayal context instead.

S-57 clause 2.4 also allows NATF at lexical level 2. That level is UCS-2, and its unit and field terminators are two bytes. parseAttrs split on the single byte and read every ATTL after the first from the wrong offset. isDoubleByteField reads the encoding from the field rather than NALL, since a cell has been reported stating NALL 0 while writing UTF-16LE. Thanks to @Sladewww for the report and the sample.

The pixel outputs draw labels with the bundled Noto Sans, so a label in another script came out as boxes. font.Font reads a TrueType collection, font.fallback holds a face consulted per codepoint, and the render tools read TILE57_FONT_FALLBACK. Nothing is bundled, so the default build keeps its size. limitations.md records that, the four-language cap, and that the PDF and vector paths embed one face per label.

The png, pdf and ascii tools take --language <iso639-2>. docs/docs/api/style.md was also missing soundings and chart_over_image, appended to the header before this branch.

Verified on an S-57 cell with both names, which labels Rossett Island by default and Rössett Inseln for any code, live and through a baked bundle. On a Chinese cell stating NALL 0, whose NOBJNM values read as 富民沙路 and 合心 beside Fuminsha Lu and Hexin, drawn with a fallback face. On a native S-101 dataset naming its features in seven languages, where two codes select two different sets of labels. Three S-57 cells bake byte identical to main. zig build, zig build test and zig fmt --check pass.

@Sladewww

Sladewww commented Sep 4, 2026

Copy link
Copy Markdown

@beetlebugorg I tested some Chinese ENC cells and found a mismatch between the declared DSSI.NALL and the actual NOBJNM encoding:

  • DSSI.NALL reads as 0 (ASCII), but the actual NOBJNM content is UTF-16LE encoded Chinese text
  • This indicates the producer did not correctly populate the NALL field

OpenCPN and GDAL ran into the same problem. Their mature approach is content heuristic detection rather than trusting NALL:

At the libiso8211 layer, check the ATVL field terminator pattern. If the field ends with 0x1E 0x00 / 0x1F 0x00 (separator + NUL), it is treated as double-byte encoding (UCS-2 / UTF-16); otherwise it falls back to single-byte (Latin-1 or ASCII).

References:

  • OpenCPN s57reader.cpp / o_senc.cpp UTF-16 detection logic
  • GDAL ddfsubfielddefn.cpp heuristic check

Suggestion: adopt the same strategy in tile57. After reading the NOBJNM ATVL value, check whether the content contains 0x00 bytes (which never appear in single-byte encodings). If present, decode as UTF-16LE (or UCS-2); otherwise respect NALL or default to Latin-1.

This ensures correct parsing of Chinese, Japanese, and Korean NOBJNM even when NALL is wrong or unset.

Happy to provide a sample for verification if useful.

@beetlebugorg

Copy link
Copy Markdown
Owner Author

A sample to work from would be great, thanks.

@Sladewww

Sladewww commented Sep 4, 2026

Copy link
Copy Markdown

@beetlebugorg What's the best way to send you the sample? Email, or something else?

@beetlebugorg

Copy link
Copy Markdown
Owner Author

It'd probably be easiest if you could encrypt it with age and attach it to this PR.

age -R <(curl -fsSL https://github.com/beetlebugorg.keys) -o <encrypted_output_filename>.age <input_cell_filename>

@Sladewww

Sladewww commented Sep 4, 2026

Copy link
Copy Markdown

sample.zip

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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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_<lang>. 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.
@beetlebugorg

Copy link
Copy Markdown
Owner Author

Thanks for the sample, that helped verify. It should be working now.

I'm going to do a follow up PR that will bake the list of available languages into the tile metadata. S101 allows for multiple.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Add NOBJNM (national language name) rendering

2 participants