Skip to content

Commit c7eed6b

Browse files
beetlebugorgclaude
andcommitted
feat(aux): ENC aux files (TXTDSC/PICREP) for the pick report
Bundle the external resources ENC features reference by filename (TXTDSC/NTXTDS text, PICREP pictures) so the cursor-pick report can show them: - cmd/chartplotter/aux.go + bake.go: collect referenced aux files during the bake and ship them in a companion "<stem>-aux.zip" (TIFF→PNG, index.json). - web/aux-store.mjs: client loads the companion aux.zip and serves files by name. - web/pick-report.mjs: render TXTDSC text + PICREP pictures inline. - web/pmtiles-source.mjs: minor supporting tweak. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f6400d7 commit c7eed6b

5 files changed

Lines changed: 321 additions & 17 deletions

File tree

cmd/chartplotter/aux.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package main
2+
3+
import (
4+
"archive/zip"
5+
"bytes"
6+
"encoding/json"
7+
"fmt"
8+
"image/png"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
13+
"golang.org/x/image/tiff"
14+
)
15+
16+
// Aux ("auxiliary") files are the external resources an ENC feature points at by
17+
// filename rather than carrying inline: TXTDSC/NTXTDS textual descriptions (.TXT)
18+
// and PICREP pictures (.TIF/.JPG). They ship in the exchange set alongside the
19+
// .000 cells. The baked tiles carry only the *filename* (in the s57 blob), so we
20+
// bundle the referenced files into a single companion archive — "<stem>-aux.zip"
21+
// — that the client fetches once and reads by filename for the pick report.
22+
23+
// isAuxContent reports whether a non-cell file is aux *content* we ship. It keys
24+
// off the content extensions (text + pictures) and excludes the exchange-set
25+
// catalogue (CATALOG.031) and readmes, which are set plumbing, not feature data.
26+
func isAuxContent(name string) bool {
27+
base := strings.ToUpper(filepath.Base(name))
28+
if strings.HasPrefix(base, "README") || strings.HasPrefix(base, "CATALOG") {
29+
return false
30+
}
31+
switch strings.ToLower(filepath.Ext(name)) {
32+
case ".txt", ".tif", ".tiff", ".jpg", ".jpeg", ".png":
33+
return true
34+
}
35+
return false
36+
}
37+
38+
// auxKey normalises an aux filename to the form features reference it by: the
39+
// bare basename, upper-cased (S-57 stores TXTDSC/PICREP values upper-cased, and
40+
// exchange sets are case-inconsistent across platforms).
41+
func auxKey(name string) string { return strings.ToUpper(filepath.Base(name)) }
42+
43+
// auxEntry is one file's record in the companion zip's index.json.
44+
type auxEntry struct {
45+
Stored string `json:"stored"` // entry name inside the zip
46+
Type string `json:"type"` // MIME type the client should make a Blob with
47+
From string `json:"from,omitempty"` // original filename, when transcoded (TIFF→PNG)
48+
}
49+
50+
// contentType maps a stored filename to the MIME type the pick report renders.
51+
func contentType(name string) string {
52+
switch strings.ToLower(filepath.Ext(name)) {
53+
case ".txt":
54+
return "text/plain"
55+
case ".png":
56+
return "image/png"
57+
case ".jpg", ".jpeg":
58+
return "image/jpeg"
59+
case ".tif", ".tiff":
60+
return "image/tiff"
61+
}
62+
return "application/octet-stream"
63+
}
64+
65+
// writeAuxZip packages the collected aux files into "<stem>-aux.zip" next to the
66+
// baked archive(s), transcoding TIFF pictures to PNG (browsers can't render TIFF)
67+
// and writing an index.json mapping each referenced filename to its stored entry.
68+
// Returns the zip's basename (for the manifest), or "" when there's nothing to ship.
69+
func writeAuxZip(stem string, aux map[string][]byte) (string, error) {
70+
if len(aux) == 0 {
71+
return "", nil
72+
}
73+
out := stem + "-aux.zip"
74+
f, err := os.Create(out)
75+
if err != nil {
76+
return "", err
77+
}
78+
zw := zip.NewWriter(f)
79+
80+
index := map[string]auxEntry{}
81+
for key, data := range aux {
82+
stored := filepath.Base(key)
83+
typ := contentType(stored)
84+
from := ""
85+
86+
// Browsers have no TIFF decoder; transcode to PNG so the picture renders
87+
// inline. On failure, ship the original and let the client offer a download.
88+
if ext := strings.ToLower(filepath.Ext(stored)); ext == ".tif" || ext == ".tiff" {
89+
if png, e := tiffToPNG(data); e == nil {
90+
from = stored
91+
stored = strings.TrimSuffix(stored, filepath.Ext(stored)) + ".png"
92+
typ = "image/png"
93+
data = png
94+
} else {
95+
fmt.Fprintf(os.Stderr, " aux: keeping %s as TIFF (transcode failed: %v)\n", stored, e)
96+
}
97+
}
98+
99+
w, err := zw.Create(stored)
100+
if err != nil {
101+
zw.Close()
102+
f.Close()
103+
return "", err
104+
}
105+
if _, err := w.Write(data); err != nil {
106+
zw.Close()
107+
f.Close()
108+
return "", err
109+
}
110+
index[key] = auxEntry{Stored: stored, Type: typ, From: from}
111+
}
112+
113+
iw, err := zw.Create("index.json")
114+
if err != nil {
115+
zw.Close()
116+
f.Close()
117+
return "", err
118+
}
119+
enc := json.NewEncoder(iw)
120+
enc.SetIndent("", " ")
121+
if err := enc.Encode(map[string]any{"version": 1, "files": index}); err != nil {
122+
zw.Close()
123+
f.Close()
124+
return "", err
125+
}
126+
127+
if err := zw.Close(); err != nil {
128+
f.Close()
129+
return "", err
130+
}
131+
if err := f.Close(); err != nil {
132+
return "", err
133+
}
134+
st, _ := os.Stat(out)
135+
fmt.Printf("wrote %d aux file(s) → %s (%.1f KB)\n", len(index), out, float64(st.Size())/1024)
136+
return filepath.Base(out), nil
137+
}
138+
139+
// tiffToPNG decodes a TIFF image and re-encodes it as PNG.
140+
func tiffToPNG(data []byte) ([]byte, error) {
141+
img, err := tiff.Decode(bytes.NewReader(data))
142+
if err != nil {
143+
return nil, err
144+
}
145+
var buf bytes.Buffer
146+
if err := png.Encode(&buf, img); err != nil {
147+
return nil, err
148+
}
149+
return buf.Bytes(), nil
150+
}

cmd/chartplotter/bake.go

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type bakeCmd struct {
2828
}
2929

3030
func (c bakeCmd) Run() error {
31-
cells, err := collectCells(c.In)
31+
cells, aux, err := collectCells(c.In)
3232
if err != nil {
3333
return err
3434
}
@@ -44,7 +44,7 @@ func (c bakeCmd) Run() error {
4444
// Per-band streaming holds only one band's geometry at a time, so it skips the
4545
// all-cells BuildBakerWithUpdates entirely.
4646
if c.Bands {
47-
return c.runBands(cells)
47+
return c.runBands(cells, aux)
4848
}
4949

5050
b, ok, err := baker.BuildBakerWithUpdates(cells, c.Overzoom, func(name string, err error) {
@@ -86,6 +86,12 @@ func (c bakeCmd) Run() error {
8686
st, _ := os.Stat(c.Out)
8787
fmt.Printf("baked %d cell(s) → %s (%d tiles, %.1f MB)\n", len(ok), c.Out, pb.Count(), float64(st.Size())/(1<<20))
8888

89+
stem := strings.TrimSuffix(c.Out, filepath.Ext(c.Out))
90+
auxFile, err := writeAuxZip(stem, aux)
91+
if err != nil {
92+
return err
93+
}
94+
8995
if c.Manifest != "" {
9096
file := c.BaseURL
9197
if file == "" {
@@ -99,6 +105,9 @@ func (c bakeCmd) Run() error {
99105
"bounds": []float64{bb.MinLon, bb.MinLat, bb.MaxLon, bb.MaxLat},
100106
}},
101107
}
108+
if auxFile != "" {
109+
man["aux"] = auxFile
110+
}
102111
mf, err := os.Create(c.Manifest)
103112
if err != nil {
104113
return err
@@ -118,7 +127,7 @@ func (c bakeCmd) Run() error {
118127
// runBands writes one gap-clipped PMTiles archive per navigational band
119128
// (<out-stem>-<slug>.pmtiles) plus a manifest tagging each with its band slug, so
120129
// the frontend loads each into its own chart-<slug> source.
121-
func (c bakeCmd) runBands(cells map[string]baker.CellData) error {
130+
func (c bakeCmd) runBands(cells map[string]baker.CellData, aux map[string][]byte) error {
122131
ext := filepath.Ext(c.Out)
123132
stem := strings.TrimSuffix(c.Out, ext)
124133
var entries []map[string]any
@@ -171,14 +180,23 @@ func (c bakeCmd) runBands(cells map[string]baker.CellData) error {
171180
}
172181
fmt.Printf("baked %d cell(s) → %d band archive(s)\n", nCells, len(entries))
173182

183+
auxFile, err := writeAuxZip(stem, aux)
184+
if err != nil {
185+
return err
186+
}
187+
174188
if c.Manifest != "" {
175189
mf, err := os.Create(c.Manifest)
176190
if err != nil {
177191
return err
178192
}
179193
enc := json.NewEncoder(mf)
180194
enc.SetIndent("", " ")
181-
if err := enc.Encode(map[string]any{"districts": entries}); err != nil {
195+
man := map[string]any{"districts": entries}
196+
if auxFile != "" {
197+
man["aux"] = auxFile
198+
}
199+
if err := enc.Encode(man); err != nil {
182200
mf.Close()
183201
return err
184202
}
@@ -201,12 +219,21 @@ func encExt(p string) string {
201219
// collectCells gathers each cell's base (.000) plus its update files (.001…) from
202220
// the inputs (zip bundles, directories, and/or individual cell files), grouped by
203221
// cell name. First base wins on a duplicate; updates accumulate.
204-
func collectCells(paths []string) (map[string]baker.CellData, error) {
222+
func collectCells(paths []string) (map[string]baker.CellData, map[string][]byte, error) {
205223
type acc struct {
206224
base []byte
207225
updates map[string][]byte
208226
}
209-
byCell := map[string]*acc{} // keyed by cell stem (e.g. US4MD81M)
227+
byCell := map[string]*acc{} // keyed by cell stem (e.g. US4MD81M)
228+
aux := map[string][]byte{} // referenced aux files, keyed by auxKey (UPPER basename)
229+
addAux := func(name string, data []byte) {
230+
if !isAuxContent(name) {
231+
return
232+
}
233+
if k := auxKey(name); aux[k] == nil {
234+
aux[k] = data
235+
}
236+
}
210237
add := func(name string, data []byte) {
211238
ext := encExt(name)
212239
if ext == "" {
@@ -232,7 +259,7 @@ func collectCells(paths []string) (map[string]baker.CellData, error) {
232259
for _, p := range paths {
233260
info, err := os.Stat(p)
234261
if err != nil {
235-
return nil, err
262+
return nil, nil, err
236263
}
237264
switch {
238265
case info.IsDir():
@@ -247,23 +274,27 @@ func collectCells(paths []string) (map[string]baker.CellData, error) {
247274
} else if strings.EqualFold(filepath.Ext(path), ".zip") {
248275
// A directory of per-cell .zip bundles (e.g. an IENC download) — unpack
249276
// each in place rather than requiring the cells be extracted first.
250-
if e := addZipCells(path, add); e != nil {
277+
if e := addZipCells(path, add, addAux); e != nil {
251278
fmt.Fprintf(os.Stderr, " skip zip %s: %v\n", path, e)
252279
}
280+
} else if isAuxContent(path) {
281+
if b, e := os.ReadFile(path); e == nil {
282+
addAux(path, b)
283+
}
253284
}
254285
return nil
255286
})
256287
if err != nil {
257-
return nil, err
288+
return nil, nil, err
258289
}
259290
case strings.EqualFold(filepath.Ext(p), ".zip"):
260-
if err := addZipCells(p, add); err != nil {
261-
return nil, err
291+
if err := addZipCells(p, add, addAux); err != nil {
292+
return nil, nil, err
262293
}
263294
case encExt(p) != "":
264295
b, e := os.ReadFile(p)
265296
if e != nil {
266-
return nil, e
297+
return nil, nil, e
267298
}
268299
add(p, b)
269300
default:
@@ -278,17 +309,18 @@ func collectCells(paths []string) (map[string]baker.CellData, error) {
278309
}
279310
cells[stem+".000"] = baker.CellData{Base: a.base, Updates: a.updates}
280311
}
281-
return cells, nil
312+
return cells, aux, nil
282313
}
283314

284-
func addZipCells(zipPath string, add func(name string, data []byte)) error {
315+
func addZipCells(zipPath string, add, addAux func(name string, data []byte)) error {
285316
zr, err := zip.OpenReader(zipPath)
286317
if err != nil {
287318
return err
288319
}
289320
defer zr.Close()
290321
for _, e := range zr.File {
291-
if encExt(e.Name) == "" {
322+
isCell := encExt(e.Name) != ""
323+
if !isCell && !isAuxContent(e.Name) {
292324
continue
293325
}
294326
rc, err := e.Open()
@@ -300,7 +332,11 @@ func addZipCells(zipPath string, add func(name string, data []byte)) error {
300332
if err != nil {
301333
return err
302334
}
303-
add(e.Name, data)
335+
if isCell {
336+
add(e.Name, data)
337+
} else {
338+
addAux(e.Name, data)
339+
}
304340
}
305341
return nil
306342
}

web/aux-store.mjs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// AuxStore — loads the companion "<stem>-aux.zip" the baker ships next to the
2+
// hosted .pmtiles. It holds the external resources an ENC feature points at by
3+
// *filename* rather than carrying inline: TXTDSC/NTXTDS textual descriptions and
4+
// PICREP pictures. The baked tiles carry only the filename (in the s57 blob), so
5+
// the pick report resolves it here, by the (upper-cased) referenced name.
6+
//
7+
// The zip is fetched whole once (it's small — the texts plus PNG-transcoded
8+
// pictures) and parsed with the same dependency-free reader the .zip import uses.
9+
// No server: it's a static file beside the charts, so it updates with them.
10+
11+
import { readCentralDirectory, extractEntry } from "./zip-import.mjs";
12+
13+
export class AuxStore {
14+
constructor() {
15+
this._index = null; // referencedName(UPPER) → { stored, type, from }
16+
this._entries = null; // stored entry name → central-directory entry
17+
this._blob = null;
18+
this._cache = new Map(); // stored → resolved { type, text|url }
19+
}
20+
21+
// Fetch + parse the aux zip. Best-effort: any failure leaves the store empty
22+
// and the pick report degrades to showing the raw filename. Returns true on load.
23+
async load(url) {
24+
try {
25+
const r = await fetch(url);
26+
if (!r.ok) throw new Error("HTTP " + r.status);
27+
const blob = await r.blob();
28+
const byName = new Map((await readCentralDirectory(blob)).map((e) => [e.name, e]));
29+
const idx = byName.get("index.json");
30+
if (!idx) throw new Error("aux zip has no index.json");
31+
const meta = JSON.parse(new TextDecoder("utf-8").decode(await extractEntry(blob, idx)));
32+
this._index = meta.files || {};
33+
this._entries = byName;
34+
this._blob = blob;
35+
return true;
36+
} catch (e) {
37+
console.warn("[aux] load failed:", e.message);
38+
return false;
39+
}
40+
}
41+
42+
// Is `ref` (a TXTDSC/PICREP filename) present in this set?
43+
has(ref) { return !!(this._index && this._index[String(ref).toUpperCase()]); }
44+
45+
// Resolve a referenced filename to { type:"text", text } or { type:"image", url }.
46+
// Text is decoded ISO-8859-1 (the ENC text charset — degree signs etc.); images
47+
// become object URLs for an <img>. Cached, so repeat picks are instant.
48+
async resolve(ref) {
49+
if (!this._index) return null;
50+
const meta = this._index[String(ref).toUpperCase()];
51+
if (!meta) return null;
52+
if (this._cache.has(meta.stored)) return this._cache.get(meta.stored);
53+
const entry = this._entries.get(meta.stored);
54+
if (!entry) return null;
55+
const bytes = await extractEntry(this._blob, entry);
56+
let out;
57+
if ((meta.type || "").startsWith("image/")) {
58+
out = { type: "image", url: URL.createObjectURL(new Blob([bytes], { type: meta.type })) };
59+
} else {
60+
out = { type: "text", text: new TextDecoder("iso-8859-1").decode(bytes) };
61+
}
62+
this._cache.set(meta.stored, out);
63+
return out;
64+
}
65+
}

0 commit comments

Comments
 (0)