diff --git a/docs/报告/CM95_S52_TXTE文字后端阶段报告_v1.md b/docs/报告/CM95_S52_TXTE文字后端阶段报告_v1.md new file mode 100644 index 0000000..cef52db --- /dev/null +++ b/docs/报告/CM95_S52_TXTE文字后端阶段报告_v1.md @@ -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 / 显示优先级。 \ No newline at end of file diff --git a/src/cm95pbf/mapping.py b/src/cm95pbf/mapping.py index 951c40d..78c0ed3 100644 --- a/src/cm95pbf/mapping.py +++ b/src/cm95pbf/mapping.py @@ -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", } ) diff --git a/src/cm95pbf/presentation.py b/src/cm95pbf/presentation.py index 2acd989..fed81a4 100644 --- a/src/cm95pbf/presentation.py +++ b/src/cm95pbf/presentation.py @@ -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 diff --git a/src/cm95pbf/s52.py b/src/cm95pbf/s52.py index eb486cc..1a286ab 100644 --- a/src/cm95pbf/s52.py +++ b/src/cm95pbf/s52.py @@ -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 = [ diff --git a/tools/cm95_s52_full_database_audit.py b/tools/cm95_s52_full_database_audit.py index d2b2579..efa5ebc 100644 --- a/tools/cm95_s52_full_database_audit.py +++ b/tools/cm95_s52_full_database_audit.py @@ -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()