feat: implement S-52 TX TE text presentation

This commit is contained in:
OpenAI Codex
2026-09-17 16:14:47 +08:00
parent c72310f35a
commit 6a768ca782
5 changed files with 217 additions and 2 deletions

View File

@@ -0,0 +1,28 @@
# CM95 S-52 TX/TE 文字后端阶段报告 v1
## 结论
本阶段完成 S-52 `TX` / `TE` 到 PBF 标量字段与 MapLibre 通用文字层的闭环验证。
- 全部 3057 条 Lookup 的文字物化异常:0。
- Presentation Library 单条 Lookup 最多 3 个 TX/TE,因此固定三个文字 slot 可完整覆盖当前资源。
- TX 参数形态统一为 9 参数,TE 统一为 10 参数。
- 按 OpenCPN 实际代码确认 HJUST:1=center、2=right、3=left;VJUST:1=bottom、2=center、3=top。
- XOFF 按平均字符宽度,YOFF 按字体高度处理。
- 修正 CM93 `_texta` 结构化文本,使用其 `text` 字段,不再把 dict 字符串直接显示。
## 福冈湾 v12 回归
测试 tile:`z12/3531/1641`
- Features:3293
- Layers:23
- hard_gate_unmapped:空
- unresolved CSP:0
- 产生通用 S-52 文字的 Feature:345
- `_texto` 错误 dict 标签:0
- Chrome / MapLibre 页面进入 READY;PBF、Sprite、Tile 正常加载。
- 浏览器截图肉眼确认:泊位号、桥名、航标名、陆地区域名、海底底质等由 TX/TE 自动显示。
测试页:`http://192.168.200.184:8877/presentation_v12.html?hud=collapsed`
下一阶段:按全国实际命中量补 `LC` 复杂线型执行后端,然后继续 AP / vector-only symbol / 显示优先级。

View File

@@ -73,6 +73,7 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset(
"s52_sector_sweep_deg",
"s52_display_priority",
"s52_lookup_rcid",
"s52_object_class",
"s52_symbol_code",
"s52_symbol_sequence",
"s52_sounding_depth_m",
@@ -84,6 +85,40 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset(
"s52_soundg_point_csp",
"s52_table_name",
"s52_text_literal",
"s52_text1_value",
"s52_text1_hjust",
"s52_text1_vjust",
"s52_text1_space",
"s52_text1_size",
"s52_text1_body_size",
"s52_text1_charspec",
"s52_text1_xoff",
"s52_text1_yoff",
"s52_text1_color",
"s52_text1_group",
"s52_text2_value",
"s52_text2_hjust",
"s52_text2_vjust",
"s52_text2_space",
"s52_text2_size",
"s52_text2_body_size",
"s52_text2_charspec",
"s52_text2_xoff",
"s52_text2_yoff",
"s52_text2_color",
"s52_text2_group",
"s52_text3_value",
"s52_text3_hjust",
"s52_text3_vjust",
"s52_text3_space",
"s52_text3_size",
"s52_text3_body_size",
"s52_text3_charspec",
"s52_text3_xoff",
"s52_text3_yoff",
"s52_text3_color",
"s52_text3_group",
"s52_text_count",
"shape_class_code",
}
)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
import re
from typing import Any, Iterable
@@ -9,6 +10,131 @@ SUPPORTED_OPCODES = frozenset(
)
OPENCPN_NATSUR_TEXT = {
1: "mud", 2: "clay", 3: "silt", 4: "sand", 5: "stone",
6: "gravel", 7: "pebbles", 8: "cobbles", 9: "rock", 11: "lava",
14: "coral", 17: "shells", 18: "boulder", 51: "Wd", 56: "Bo",
}
def _strip_quotes(value: str) -> str:
text = str(value or "").strip()
if len(text) >= 2 and text[0] in {"'", '"'} and text[-1] == text[0]:
return text[1:-1]
return text
def _resolve_text_param(value: str, attrs: dict[str, Any]) -> str:
raw = str(value or "").strip()
quoted = len(raw) >= 2 and raw[0] in {"'", '"'} and raw[-1] == raw[0]
if quoted:
return raw[1:-1]
if "=" in raw:
key, default = raw.split("=", 1)
current = attrs.get(key)
if current not in (None, ""):
return str(current)
return default.rstrip(")")
return raw.rstrip(")")
def _text_attr_value(name: str, attrs: dict[str, Any]) -> str | None:
key = _strip_quotes(name)
if key not in attrs or attrs.get(key) in (None, ""):
return None
value = attrs.get(key)
if isinstance(value, dict):
text_value = value.get("text")
if text_value not in (None, ""):
return str(text_value)
return None
if key == "NATSUR":
values = value if isinstance(value, (list, tuple)) else str(value).split(",")
out = []
for item in values:
try:
out.append(OPENCPN_NATSUR_TEXT.get(int(item), "unk"))
except (TypeError, ValueError):
out.append("unk")
return ",".join(out)
if isinstance(value, (list, tuple)):
return ",".join(str(x) for x in value)
return str(value)
def _format_te_value(fmt_raw: str, attr_raw: str, attrs: dict[str, Any]) -> str | None:
value = _text_attr_value(attr_raw, attrs)
if value is None:
return None
fmt = _strip_quotes(fmt_raw).replace("lf", "f")
match = re.search(r"%(?:[-+0 #]*\d*(?:\.\d+)?)?([csfdi])", fmt)
try:
if match is None:
return fmt
conversion = match.group(1)
if conversion == "f":
return fmt % float(value)
if conversion in {"d", "i"}:
return fmt % int(float(value))
if conversion == "c":
return fmt % (int(float(value)) if str(value).lstrip("+-").isdigit() else str(value)[0])
return fmt % value
except (TypeError, ValueError, IndexError):
return None
def materialize_text_hints(instruction: str, attrs: dict[str, Any]) -> dict[str, Any]:
"""Materialize OpenCPN-compatible TX/TE rules into at most three text slots."""
out: dict[str, Any] = {}
slot = 0
for rule in parse_instruction_string(instruction):
if rule.opcode not in {"TX", "TE"}:
continue
args = rule.args
if rule.opcode == "TX":
if len(args) < 9:
continue
first = str(args[0]).strip()
if len(first) >= 2 and first[0] in {"'", '"'} and first[-1] == first[0]:
text = _strip_quotes(first)
else:
text = _text_attr_value(first, attrs)
layout = args[1:9]
else:
if len(args) < 10:
continue
text = _format_te_value(args[0], args[1], attrs)
layout = args[2:10]
if text in (None, ""):
continue
slot += 1
if slot > 3:
break
prefix = f"s52_text{slot}_"
out[prefix + "value"] = str(text)
out[prefix + "hjust"] = _resolve_text_param(layout[0], attrs)
out[prefix + "vjust"] = _resolve_text_param(layout[1], attrs)
out[prefix + "space"] = _resolve_text_param(layout[2], attrs)
charspec = _resolve_text_param(layout[3], attrs)
out[prefix + "charspec"] = charspec
if len(charspec) >= 4 and charspec[3:].isdigit():
body_size = int(charspec[3:])
out[prefix + "body_size"] = body_size
# OpenCPN normalizes S-52 body size to the user ChartText base
# instead of rendering 18/20 as literal pixel sizes. Use a
# deterministic 10px web base; a future NavSea text-scale setting
# can shift this derived size without losing the raw body size.
normalized = int((min(20, max(0, body_size)) / 20.0) * 4.0)
out[prefix + "size"] = max(10, 10 + normalized)
out[prefix + "xoff"] = _resolve_text_param(layout[4], attrs)
out[prefix + "yoff"] = _resolve_text_param(layout[5], attrs)
out[prefix + "color"] = _resolve_text_param(layout[6], attrs)
out[prefix + "group"] = _resolve_text_param(layout[7], attrs)
if slot:
out["s52_text_count"] = slot
return out
class PresentationParseError(ValueError):
pass

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any, Iterable
from .presentation import static_rule_hints
from .presentation import materialize_text_hints, static_rule_hints
from .csp import S52MarinerSettings, resolve_csp_hints
@@ -438,6 +438,7 @@ class S52LookupRegistry:
"s52_table_name": str(row.get("table_name") or ""),
}
out.update(dict(row.get("_static_hints") or {}))
out.update(materialize_text_hints(str(row.get("instruction") or ""), normalized_attrs))
if lookup_class != str(object_class):
out["s52_object_class"] = lookup_class
procedures = [

View File

@@ -114,6 +114,12 @@ def main() -> int:
unsupported_csp_counts = Counter()
opcode_counts = Counter()
direct_symbol_counts = Counter()
pattern_counts = Counter()
complex_line_counts = Counter()
area_color_counts = Counter()
simple_line_counts = Counter()
tx_rule_counts = Counter()
te_rule_counts = Counter()
substitution_counts = Counter()
sql = (
@@ -165,7 +171,19 @@ def main() -> int:
opcode_counts[opcode] += 1
args_list = rule.get("args") or []
if opcode == "SY" and args_list:
direct_symbol_counts[str(args_list[0])[:8]] += 1
direct_symbol_counts[str(args_list[0]).strip("'\"")[:8]] += 1
elif opcode == "AP" and args_list:
pattern_counts[str(args_list[0]).strip("'\"")[:8]] += 1
elif opcode == "LC" and args_list:
complex_line_counts[str(args_list[0]).strip("'\"")[:8]] += 1
elif opcode == "AC" and args_list:
area_color_counts[str(args_list[0]).strip("'\"")] += 1
elif opcode == "LS" and len(args_list) >= 3:
simple_line_counts[tuple(str(x).strip("'\"") for x in args_list[:3])] += 1
elif opcode == "TX":
tx_rule_counts[tuple(str(x) for x in args_list)] += 1
elif opcode == "TE":
te_rule_counts[tuple(str(x) for x in args_list)] += 1
if opcode == "CS" and args_list:
name = str(args_list[0])[:8]
csp_counts[name] += 1
@@ -202,6 +220,13 @@ def main() -> int:
"implemented_csp_counts": dict(implemented_csp_counts),
"unsupported_csp_counts": dict(unsupported_csp_counts),
"direct_symbol_top100": direct_symbol_counts.most_common(100),
"symbol_resource_counts": direct_symbol_counts.most_common(),
"pattern_resource_counts": pattern_counts.most_common(),
"complex_line_resource_counts": complex_line_counts.most_common(),
"area_color_counts": area_color_counts.most_common(),
"simple_line_rule_counts": [[list(k), v] for k, v in simple_line_counts.most_common()],
"tx_rule_counts": [[list(k), v] for k, v in tx_rule_counts.most_common()],
"te_rule_counts": [[list(k), v] for k, v in te_rule_counts.most_common()],
"class_substitution_counts": [
{"source_object_class": k[0], "s52_object_class": k[1], "geometry_type": k[2], "count": v}
for k, v in substitution_counts.most_common()