feat: implement S-52 TX TE text presentation
This commit is contained in:
28
docs/报告/CM95_S52_TXTE文字后端阶段报告_v1.md
Normal file
28
docs/报告/CM95_S52_TXTE文字后端阶段报告_v1.md
Normal 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 / 显示优先级。
|
||||||
@@ -73,6 +73,7 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset(
|
|||||||
"s52_sector_sweep_deg",
|
"s52_sector_sweep_deg",
|
||||||
"s52_display_priority",
|
"s52_display_priority",
|
||||||
"s52_lookup_rcid",
|
"s52_lookup_rcid",
|
||||||
|
"s52_object_class",
|
||||||
"s52_symbol_code",
|
"s52_symbol_code",
|
||||||
"s52_symbol_sequence",
|
"s52_symbol_sequence",
|
||||||
"s52_sounding_depth_m",
|
"s52_sounding_depth_m",
|
||||||
@@ -84,6 +85,40 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset(
|
|||||||
"s52_soundg_point_csp",
|
"s52_soundg_point_csp",
|
||||||
"s52_table_name",
|
"s52_table_name",
|
||||||
"s52_text_literal",
|
"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",
|
"shape_class_code",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
|
import re
|
||||||
from typing import Any, Iterable
|
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):
|
class PresentationParseError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Iterable
|
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
|
from .csp import S52MarinerSettings, resolve_csp_hints
|
||||||
|
|
||||||
|
|
||||||
@@ -438,6 +438,7 @@ class S52LookupRegistry:
|
|||||||
"s52_table_name": str(row.get("table_name") or ""),
|
"s52_table_name": str(row.get("table_name") or ""),
|
||||||
}
|
}
|
||||||
out.update(dict(row.get("_static_hints") 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):
|
if lookup_class != str(object_class):
|
||||||
out["s52_object_class"] = lookup_class
|
out["s52_object_class"] = lookup_class
|
||||||
procedures = [
|
procedures = [
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ def main() -> int:
|
|||||||
unsupported_csp_counts = Counter()
|
unsupported_csp_counts = Counter()
|
||||||
opcode_counts = Counter()
|
opcode_counts = Counter()
|
||||||
direct_symbol_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()
|
substitution_counts = Counter()
|
||||||
|
|
||||||
sql = (
|
sql = (
|
||||||
@@ -165,7 +171,19 @@ def main() -> int:
|
|||||||
opcode_counts[opcode] += 1
|
opcode_counts[opcode] += 1
|
||||||
args_list = rule.get("args") or []
|
args_list = rule.get("args") or []
|
||||||
if opcode == "SY" and args_list:
|
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:
|
if opcode == "CS" and args_list:
|
||||||
name = str(args_list[0])[:8]
|
name = str(args_list[0])[:8]
|
||||||
csp_counts[name] += 1
|
csp_counts[name] += 1
|
||||||
@@ -202,6 +220,13 @@ def main() -> int:
|
|||||||
"implemented_csp_counts": dict(implemented_csp_counts),
|
"implemented_csp_counts": dict(implemented_csp_counts),
|
||||||
"unsupported_csp_counts": dict(unsupported_csp_counts),
|
"unsupported_csp_counts": dict(unsupported_csp_counts),
|
||||||
"direct_symbol_top100": direct_symbol_counts.most_common(100),
|
"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": [
|
"class_substitution_counts": [
|
||||||
{"source_object_class": k[0], "s52_object_class": k[1], "geometry_type": k[2], "count": v}
|
{"source_object_class": k[0], "s52_object_class": k[1], "geometry_type": k[2], "count": v}
|
||||||
for k, v in substitution_counts.most_common()
|
for k, v in substitution_counts.most_common()
|
||||||
|
|||||||
Reference in New Issue
Block a user