quantized-lab 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Layer-level decoding for ``.opj`` graph windows: the layer-continuation
|
|
2
|
+
block detector and the per-layer plot-state builder, split verbatim out of
|
|
3
|
+
``figures.py`` (2026-07-11, MAIN #8h — the 500-line guard, no logic
|
|
4
|
+
changes). ``figures.py`` keeps the window walk (``extract_figures``) and
|
|
5
|
+
the byte-level module docstring these helpers were isolated and validated
|
|
6
|
+
against; this module holds the layer-scoped pieces it calls per layer:
|
|
7
|
+
|
|
8
|
+
- ``_axis`` / ``_log_heuristic`` / ``_y_scale_flag`` — the axis-range
|
|
9
|
+
float64 triples and the exact Y lin/log flag at payload offset 98/99,
|
|
10
|
+
with the decade heuristic as the only fallback.
|
|
11
|
+
- ``_is_layer_block`` / ``_LAYER_HEAD_BYTES`` — the layer-continuation
|
|
12
|
+
block detector (head byte 0x1f ordinary / 0x17 stacked-panel / 0x5f
|
|
13
|
+
merge-graph; see the comment at ``_LAYER_HEAD_BYTES``).
|
|
14
|
+
- ``_build_layer`` — one layer's full plot-state dict: axis ranges/steps,
|
|
15
|
+
curve count, named-object text-bucket routing, positioned annotation
|
|
16
|
+
marks, region shades, legend labels/position, per-layer curve bindings.
|
|
17
|
+
|
|
18
|
+
See ``figures.py``'s module docstring for the full byte-level trail and
|
|
19
|
+
corpus validation.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
import struct
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from quantized.io.origin_project.annotation_marks import (
|
|
29
|
+
_AUTO_TITLE,
|
|
30
|
+
_clean_annotations,
|
|
31
|
+
build_mark,
|
|
32
|
+
frac_to_data,
|
|
33
|
+
opj_object_fractions,
|
|
34
|
+
)
|
|
35
|
+
from quantized.io.origin_project.figure_geometry import opj_layer_frame
|
|
36
|
+
from quantized.io.origin_project.figure_text import (
|
|
37
|
+
_LEGEND_RE,
|
|
38
|
+
_first_title,
|
|
39
|
+
_object_bucket,
|
|
40
|
+
_object_text,
|
|
41
|
+
_parse_legend_labels,
|
|
42
|
+
_texts_in,
|
|
43
|
+
)
|
|
44
|
+
from quantized.io.origin_project.opj_curves import extract_curves
|
|
45
|
+
from quantized.io.origin_project.opj_shapes import SHAPE_TYPE, build_region_shade
|
|
46
|
+
from quantized.io.origin_project.origin_richtext import clean_richtext
|
|
47
|
+
from quantized.io.origin_project.windows import _cstring
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"_LAYER_HEAD_BYTES",
|
|
51
|
+
"_axis",
|
|
52
|
+
"_build_layer",
|
|
53
|
+
"_is_layer_block",
|
|
54
|
+
"_log_heuristic",
|
|
55
|
+
"_y_scale_flag",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Fixed payload offset of a 133-byte object header's own ASCII name (see
|
|
59
|
+
# module docstring). 24 bytes is ample for every name observed corpus-wide
|
|
60
|
+
# (the longest, "__FRAMESRCDATAINFOS", is 19 chars).
|
|
61
|
+
_OBJ_NAME_OFFSET = 70
|
|
62
|
+
_OBJ_NAME_LIMIT = 24
|
|
63
|
+
|
|
64
|
+
# Y-axis scale flag: 2 bytes at the layer-continuation payload's offset 98/99
|
|
65
|
+
# (see the module docstring). Any other value falls back to the heuristic.
|
|
66
|
+
_Y_SCALE_FLAG_OFFSET = 98
|
|
67
|
+
_Y_LIN_FLAG = bytes([0x01, 0x00])
|
|
68
|
+
_Y_LOG_FLAG = bytes([0x08, 0x01])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _axis(p: bytes, base: int) -> tuple[float, float]:
|
|
72
|
+
lo, hi = struct.unpack_from("<d", p, base)[0], struct.unpack_from("<d", p, base + 8)[0]
|
|
73
|
+
return float(lo), float(hi)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _log_heuristic(lo: float, hi: float) -> bool:
|
|
77
|
+
return lo > 0 and hi > 0 and hi / lo >= 1000.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _y_scale_flag(payload: bytes) -> bool | None:
|
|
81
|
+
"""Exact Y lin/log flag from the layer-continuation payload, or ``None``
|
|
82
|
+
when the block is too short or the byte pair is unrecognized (caller
|
|
83
|
+
falls back to ``_log_heuristic``) -- see the module docstring."""
|
|
84
|
+
if len(payload) < _Y_SCALE_FLAG_OFFSET + 2:
|
|
85
|
+
return None
|
|
86
|
+
flag = payload[_Y_SCALE_FLAG_OFFSET : _Y_SCALE_FLAG_OFFSET + 2]
|
|
87
|
+
if flag == _Y_LIN_FLAG:
|
|
88
|
+
return False
|
|
89
|
+
if flag == _Y_LOG_FLAG:
|
|
90
|
+
return True
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# A layer-continuation block's 3rd payload byte -- normally 0x1f (every
|
|
95
|
+
# window's first layer, and every subsequent OVERLAID layer, e.g. a
|
|
96
|
+
# double-Y graph's 2nd layer: validated on Moke's Graph7 AND on
|
|
97
|
+
# SLD_DoubleY.otp's two-layer double-Y template, both 0x1f/0x1f). A second,
|
|
98
|
+
# rarer value, 0x1f - 0x08 = 0x17, appears ONLY as a subsequent STACKED/
|
|
99
|
+
# TILED-PANEL layer (Origin's "N Panels" layout, a structurally different
|
|
100
|
+
# multi-layer mechanism from double-Y overlay) -- isolated on Moke's Graph4
|
|
101
|
+
# (layers 0x1f then 0x17, second layer's axis range (400.0, 1500.0) matches
|
|
102
|
+
# the oracle's 2nd-layer Y range exactly) and its composite copy inside
|
|
103
|
+
# Graph10. Corpus-wide grep (Moke/XRD/SuperlatticeFits/PNR/hc2convert/XMCD/
|
|
104
|
+
# MnN_Diffusion_PNR) finds 0x17 nowhere else at all -- and never outside a
|
|
105
|
+
# graph window's own span -- so accepting it is not a guess, it is confirmed
|
|
106
|
+
# structural evidence, not (yet) a generalized "any byte works" heuristic:
|
|
107
|
+
# only these two exact values are recognized; anything else falls through
|
|
108
|
+
# and is not treated as a layer boundary.
|
|
109
|
+
#
|
|
110
|
+
# A THIRD value, 0x1f | 0x40 = 0x5f, marks every layer of an Origin "Merge
|
|
111
|
+
# Graph Windows" result (decode-plan item 40, isolated 2026-07-09 on
|
|
112
|
+
# PNR.opj's ``Graph30``-``Graph33``/``PNRDWMerge``/``PNRmerge_Jan16`` --
|
|
113
|
+
# these 6 graph windows were previously invisible to `extract_figures`
|
|
114
|
+
# entirely, since their FIRST post-header block already reads 0x5f, so the
|
|
115
|
+
# window-vs-worksheet gate in `extract_figures` rejected them outright).
|
|
116
|
+
# Every merged window's 0x5f blocks decode with the SAME fixed-offset axis
|
|
117
|
+
# record `_build_layer` already reads (x/y from-to-step, the y-scale flag,
|
|
118
|
+
# the source_hint cstring) -- confirmed by cross-checking the independent
|
|
119
|
+
# `extract_curves` anchor scan over each window's span, which resolves real
|
|
120
|
+
# curves against real, currently-imported books (e.g. `PNRDWMerge`'s 48
|
|
121
|
+
# curves all bind to `DW*` books; `Graph31`'s 18 curves all bind to
|
|
122
|
+
# `Book35`/`Book36`/`Book37`) -- not synthetic axis-shaped garbage. A merge
|
|
123
|
+
# window's own layer count (2-9 in this corpus) is the total layer count
|
|
124
|
+
# across every graph merged into it, exactly the existing multi-layer/
|
|
125
|
+
# multi-panel model already built for genuine double-Y and stacked-panel
|
|
126
|
+
# windows (`_build_layer` is agnostic to *why* a layer boundary exists).
|
|
127
|
+
_LAYER_HEAD_BYTES = (0x1F, 0x17, 0x5F)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_layer_block(payload: bytes) -> bool:
|
|
131
|
+
"""A layer-continuation block: head ``00 00 <0x1f|0x17|0x5f> 00``, ≥90 B
|
|
132
|
+
(the axis-range triples' fixed offsets need at least that much). Graph
|
|
133
|
+
windows repeat this block once per layer (see module docstring); this
|
|
134
|
+
same detector both identifies a window header as a *graph* header
|
|
135
|
+
(checked against the block right after it -- the first layer, 0x1f for
|
|
136
|
+
an ordinary graph or 0x5f for every layer of a merged one) and finds
|
|
137
|
+
every subsequent layer boundary inside one (0x1f, the rarer 0x17, or
|
|
138
|
+
0x5f -- see ``_LAYER_HEAD_BYTES``)."""
|
|
139
|
+
return (
|
|
140
|
+
len(payload) >= 90
|
|
141
|
+
and payload[0] == 0
|
|
142
|
+
and payload[1] == 0
|
|
143
|
+
and payload[2] in _LAYER_HEAD_BYTES
|
|
144
|
+
and payload[3] == 0
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_layer(
|
|
149
|
+
blocks: list[tuple[int, bytes]],
|
|
150
|
+
id_map: dict[int, tuple[str, str]],
|
|
151
|
+
x_columns: dict[str, str],
|
|
152
|
+
name: str,
|
|
153
|
+
layer_no: int,
|
|
154
|
+
start: int,
|
|
155
|
+
end: int,
|
|
156
|
+
) -> dict[str, Any]:
|
|
157
|
+
"""One layer's plot-state dict: axis ranges from its own
|
|
158
|
+
layer-continuation block (``blocks[start]``); curves/curve-count/
|
|
159
|
+
annotations/titles/legend scoped to the half-open ``blocks[start:end)``
|
|
160
|
+
-- exactly this layer's own content, since layer records, curve anchors,
|
|
161
|
+
and graph-child objects are sequential within a window span (module
|
|
162
|
+
docstring; validated against Moke's Graph4/Graph7/Graph10).
|
|
163
|
+
|
|
164
|
+
Text routing (2026-07-05): walks the block span tracking which named
|
|
165
|
+
object is "current" (``_object_bucket``) and files each block's
|
|
166
|
+
recovered text into that bucket -- ``x_title``/``y_title``/``y2_title``
|
|
167
|
+
(single string), ``legend`` (raw ``\\l(n)`` lines, parsed after the loop),
|
|
168
|
+
or ``annotations`` (floating text, cleaned of internal-storage noise).
|
|
169
|
+
The bucket starts as ``"annotations"`` (so text preceding the first
|
|
170
|
+
header, or an entire layer with no resolvable header at all, degrades to
|
|
171
|
+
the pre-2026-07-05 flat scrape rather than losing text). A curve header
|
|
172
|
+
(``payload[2] == 0x07``) only increments ``n_curves``, exactly as
|
|
173
|
+
before -- it does NOT change the current bucket, since a real curve's
|
|
174
|
+
own style/DataPlot body never produces recognizable text anyway (every
|
|
175
|
+
instance checked corpus-wide already scans to ``[]``) and a curve
|
|
176
|
+
template can legitimately sit *between* two objects that share one
|
|
177
|
+
bucket (e.g. a layer's fixed 2-curve template between ``Legend`` and a
|
|
178
|
+
later ``Text`` annotation). Any OTHER named or unresolved header does
|
|
179
|
+
switch the bucket, including to ``"ignore"`` for uninteresting objects.
|
|
180
|
+
"""
|
|
181
|
+
layer_payload = blocks[start][1]
|
|
182
|
+
x_from, x_to = _axis(layer_payload, 15)
|
|
183
|
+
y_from, y_to = _axis(layer_payload, 58)
|
|
184
|
+
# The triples' third double IS the major tick increment (88/88 exact vs
|
|
185
|
+
# the axis_ticks COM oracle, 2026-07-06 -- 13.2 #8).
|
|
186
|
+
x_step = float(struct.unpack_from("<d", layer_payload, 31)[0])
|
|
187
|
+
y_step = float(struct.unpack_from("<d", layer_payload, 74)[0])
|
|
188
|
+
hint = _cstring(layer_payload, 208, 24) or ""
|
|
189
|
+
y_log = _y_scale_flag(layer_payload)
|
|
190
|
+
# Final scale flags, needed up-front: positions (annotation marks, the
|
|
191
|
+
# legend box) interpolate in log10 space on log axes (annotation_marks).
|
|
192
|
+
x_log = _log_heuristic(x_from, x_to) # no isolated X flag found in .opj
|
|
193
|
+
y_log_final = y_log if y_log is not None else _log_heuristic(y_from, y_to)
|
|
194
|
+
frame = opj_layer_frame(layer_payload)
|
|
195
|
+
n_curves = 0
|
|
196
|
+
all_texts: list[str] = [] # every recognized text run -- feeds legend_ns/n_curves as before
|
|
197
|
+
bucket_texts: dict[str, list[str]] = {
|
|
198
|
+
"x_title": [],
|
|
199
|
+
"y_title": [],
|
|
200
|
+
"y2_title": [],
|
|
201
|
+
"legend": [],
|
|
202
|
+
"annotations": [],
|
|
203
|
+
}
|
|
204
|
+
bucket = "annotations" # default until a named header switches it (see docstring)
|
|
205
|
+
# Positioned annotation marks: one per Text*/Line* OBJECT, its fraction
|
|
206
|
+
# pair read from the header payload (annotation_marks.py) and its lines
|
|
207
|
+
# grouped until the next named header — multi-line text stays ONE mark.
|
|
208
|
+
marks: list[dict[str, Any]] = []
|
|
209
|
+
mark_fracs: tuple[float, float] | None = None
|
|
210
|
+
mark_lines: list[str] = []
|
|
211
|
+
mark_active = False # only text owned by a named Text*/Line* header groups
|
|
212
|
+
# The Legend object's own header carries the SAME position fields
|
|
213
|
+
# every text object does (§13.2 #3, 2026-07-06) — box top-left.
|
|
214
|
+
legend_fracs: tuple[float, float] | None = None
|
|
215
|
+
# Region-shape objects (Rect* bands, item 41 — see opj_shapes.py).
|
|
216
|
+
shades: list[dict[str, Any]] = []
|
|
217
|
+
|
|
218
|
+
def _flush_mark() -> None:
|
|
219
|
+
nonlocal mark_fracs
|
|
220
|
+
mark = build_mark(
|
|
221
|
+
mark_fracs, mark_lines, x_from, x_to, y_from, y_to, x_log, y_log_final
|
|
222
|
+
)
|
|
223
|
+
if mark is not None:
|
|
224
|
+
marks.append(mark)
|
|
225
|
+
mark_fracs = None
|
|
226
|
+
mark_lines.clear()
|
|
227
|
+
|
|
228
|
+
for k in range(start + 1, end):
|
|
229
|
+
size, payload = blocks[k]
|
|
230
|
+
if size == 133 and len(payload) > 2 and payload[2] == 0x07:
|
|
231
|
+
n_curves += 1 # the curve counter only -- does not touch `bucket` (see docstring)
|
|
232
|
+
continue
|
|
233
|
+
if size == 133 and len(payload) > 2 and payload[2] == SHAPE_TYPE:
|
|
234
|
+
# A closed-shape graphic object (Rect* region band, item 41):
|
|
235
|
+
# its geometry + fill live in the body block right after the
|
|
236
|
+
# header — decoded via opj_shapes; its header still ends any
|
|
237
|
+
# open annotation grouping exactly like other named headers.
|
|
238
|
+
bucket = "ignore"
|
|
239
|
+
_flush_mark()
|
|
240
|
+
mark_active = False
|
|
241
|
+
body = blocks[k + 1][1] if k + 1 < end else b""
|
|
242
|
+
shade = build_region_shade(body, x_from, x_to, y_from, y_to, x_log, y_log_final)
|
|
243
|
+
if shade is not None:
|
|
244
|
+
shades.append(shade)
|
|
245
|
+
continue
|
|
246
|
+
if size == 133 and len(payload) > 2:
|
|
247
|
+
bucket = _object_bucket(_cstring(payload, _OBJ_NAME_OFFSET, _OBJ_NAME_LIMIT))
|
|
248
|
+
_flush_mark()
|
|
249
|
+
mark_active = bucket == "annotations"
|
|
250
|
+
if mark_active:
|
|
251
|
+
mark_fracs = opj_object_fractions(payload, frame)
|
|
252
|
+
if bucket == "legend" and legend_fracs is None:
|
|
253
|
+
legend_fracs = opj_object_fractions(payload, frame)
|
|
254
|
+
# Never text-scan the header block itself: its geometry floats can
|
|
255
|
+
# contain printable accidents that would land in the just-set
|
|
256
|
+
# bucket (hc2convert's Graph13-18 all surfaced a bogus y_title
|
|
257
|
+
# "TEP]" from 4 printable bytes inside the YL header's own
|
|
258
|
+
# position doubles). Real object text lives in the CONTENT block
|
|
259
|
+
# that follows, never in the header.
|
|
260
|
+
continue
|
|
261
|
+
if size < 1200 and not _is_layer_block(payload):
|
|
262
|
+
# A block OWNED by a Text/Line annotation header that is exactly
|
|
263
|
+
# a NUL-terminated string is the object's verbatim text -- exact,
|
|
264
|
+
# no noise heuristics (they drop 'X'/'*'/'Si' peak labels). Split
|
|
265
|
+
# per line so the flat annotations bucket keeps the same per-line
|
|
266
|
+
# shape both containers use (marks re-join with newline anyway).
|
|
267
|
+
exact = _object_text(payload) if mark_active else None
|
|
268
|
+
if exact is not None:
|
|
269
|
+
found = [line for line in exact.split("\n") if line.strip()]
|
|
270
|
+
else:
|
|
271
|
+
found = _texts_in(payload)
|
|
272
|
+
all_texts.extend(found)
|
|
273
|
+
if bucket in bucket_texts:
|
|
274
|
+
bucket_texts[bucket].extend(found)
|
|
275
|
+
if mark_active:
|
|
276
|
+
mark_lines.extend(found)
|
|
277
|
+
_flush_mark()
|
|
278
|
+
titles = [
|
|
279
|
+
t for t in bucket_texts["annotations"] if not _AUTO_TITLE.match(t) and "\\l(" not in t
|
|
280
|
+
]
|
|
281
|
+
legend_ns = [int(n) for t in all_texts for n in _LEGEND_RE.findall(t)]
|
|
282
|
+
return {
|
|
283
|
+
"name": name,
|
|
284
|
+
"layer": layer_no,
|
|
285
|
+
"x_from": x_from,
|
|
286
|
+
"x_to": x_to,
|
|
287
|
+
"x_log": x_log,
|
|
288
|
+
"y_from": y_from,
|
|
289
|
+
"y_to": y_to,
|
|
290
|
+
"y_log": y_log_final,
|
|
291
|
+
# The layer frame rect in page units (multi-panel layout, §13.2 #7);
|
|
292
|
+
# None when the quad is missing/degenerate.
|
|
293
|
+
"frame": (
|
|
294
|
+
dict(zip(("left", "top", "right", "bottom"), frame, strict=True))
|
|
295
|
+
if frame is not None
|
|
296
|
+
else None
|
|
297
|
+
),
|
|
298
|
+
# Major tick increments (the axis triples' step doubles, 13.2 #8).
|
|
299
|
+
"x_step": x_step if math.isfinite(x_step) and x_step > 0 else None,
|
|
300
|
+
"y_step": y_step if math.isfinite(y_step) and y_step > 0 else None,
|
|
301
|
+
"source_hint": hint,
|
|
302
|
+
"n_curves": max(legend_ns) if legend_ns else n_curves,
|
|
303
|
+
"annotations": [clean_richtext(a) for a in _clean_annotations(titles)[:12]],
|
|
304
|
+
# Positioned floating text (box top-left, data coords) — only objects
|
|
305
|
+
# whose header fractions decoded; the rest stay text-only above.
|
|
306
|
+
"annotation_marks": marks,
|
|
307
|
+
# Filled region-shape objects (Rect* bands) in data coords (item 41).
|
|
308
|
+
"region_shades": shades,
|
|
309
|
+
"x_title": _first_title(bucket_texts["x_title"]),
|
|
310
|
+
"y_title": _first_title(bucket_texts["y_title"]),
|
|
311
|
+
"y2_title": _first_title(bucket_texts["y2_title"]),
|
|
312
|
+
"legend_labels": _parse_legend_labels(bucket_texts["legend"]),
|
|
313
|
+
# Raw legend lines for the WINDOW-level composite-legend pass
|
|
314
|
+
# (`distribute_legend_layers` — dotted ``\\l(layer.plot)`` entries
|
|
315
|
+
# belong to other layers' dicts); popped before figures ship.
|
|
316
|
+
"_legend_raw": list(bucket_texts["legend"]),
|
|
317
|
+
# Legend box top-left in data coords, or None (never guessed).
|
|
318
|
+
"legend_pos": (
|
|
319
|
+
dict(
|
|
320
|
+
zip(
|
|
321
|
+
("x", "y"),
|
|
322
|
+
frac_to_data(
|
|
323
|
+
*legend_fracs, x_from, x_to, y_from, y_to, x_log, y_log_final
|
|
324
|
+
),
|
|
325
|
+
strict=True,
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
if legend_fracs is not None
|
|
329
|
+
else None
|
|
330
|
+
),
|
|
331
|
+
# item 2: curves attributed positionally to THIS layer only.
|
|
332
|
+
"curves": extract_curves(blocks, start, end, id_map, x_columns),
|
|
333
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Shared figure-text helpers: raw-run scanning, object-name routing, titles,
|
|
2
|
+
legend-label parsing. Split out of ``figures.py`` (2026-07-06, the 500-line
|
|
3
|
+
guard) because BOTH containers' figure decoders (``figures.py``,
|
|
4
|
+
``figures_opju.py``) and the CPYUA framed-text router
|
|
5
|
+
(``opju_figure_text.py``) consume the exact same pipeline — one home keeps
|
|
6
|
+
the two containers' text semantics identical by construction.
|
|
7
|
+
|
|
8
|
+
Pure leaf: imports only ``origin_richtext``. See ``figures.py``'s module
|
|
9
|
+
docstring for the byte-level trail these helpers were validated against.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterator, Sequence
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from quantized.io.origin_project.origin_richtext import clean_richtext
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"_LEGEND_RE",
|
|
22
|
+
"_RICHTEXT_MARK",
|
|
23
|
+
"_TITLE_OBJECT_BUCKETS",
|
|
24
|
+
"_first_title",
|
|
25
|
+
"_object_bucket",
|
|
26
|
+
"_object_text",
|
|
27
|
+
"_parse_legend_labels",
|
|
28
|
+
"_parse_legend_layers",
|
|
29
|
+
"_texts_in",
|
|
30
|
+
"distribute_legend_layers",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# A legend entry's swatch marker — counts curves (``n_curves``) and detects
|
|
34
|
+
# legend text in raw scans.
|
|
35
|
+
_LEGEND_RE = re.compile(r"\\l\((\d+)\)")
|
|
36
|
+
|
|
37
|
+
_WORDY = re.compile(r"[A-Za-z0-9 ()\[\].,%/+°:=-]")
|
|
38
|
+
|
|
39
|
+
# A well-formed Origin rich-text escape (see ``origin_richtext``): a legend
|
|
40
|
+
# swatch ``\l(n)``, a formatting run ``\g( \b( \i( \u( \+( \-(``, a font/colour
|
|
41
|
+
# run ``\f:Name(`` / ``\c<n>(``, or a character-code atom ``\(40)`` / ``\(x2225)``.
|
|
42
|
+
# Binary noise essentially never forms these 3+-byte structured sequences, so a
|
|
43
|
+
# string containing one is user text by construction — the wordiness heuristic
|
|
44
|
+
# below must not veto it (it mis-scored short titles like ``2\g(q)`` = "2θ",
|
|
45
|
+
# whose escape syntax IS most of the string).
|
|
46
|
+
_RICHTEXT_MARK = re.compile(r"\\(?:[gbiul+\-]\(|\((?:\d|[xX][0-9a-fA-F])|f:|c\d+\()")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _texts_in(payload: bytes) -> list[str]:
|
|
50
|
+
"""Human-looking text runs of a text-object body (annotations, titles, legend).
|
|
51
|
+
|
|
52
|
+
Binary blocks are full of short printable accidents and internal tokens —
|
|
53
|
+
keep only strings that read like labels: mostly word-ish characters, at
|
|
54
|
+
least two letters, not internal ``_``/``@${`` machinery. A string carrying
|
|
55
|
+
a well-formed rich-text escape (``_RICHTEXT_MARK``) is kept verbatim
|
|
56
|
+
without the wordiness test — escape syntax is proof of user text, and the
|
|
57
|
+
escapes themselves (backslashes, single Symbol-font letters) are exactly
|
|
58
|
+
what the heuristic mis-scores.
|
|
59
|
+
"""
|
|
60
|
+
out = []
|
|
61
|
+
for m in re.finditer(rb"[\x20-\x7e\\]{3,}", payload):
|
|
62
|
+
s = m.group().decode("latin1").strip()
|
|
63
|
+
if not s or s.startswith(("@${", "_", "*")):
|
|
64
|
+
continue
|
|
65
|
+
if _RICHTEXT_MARK.search(s): # incl. legend \l(n): keep verbatim
|
|
66
|
+
out.append(s)
|
|
67
|
+
continue
|
|
68
|
+
letters = sum(c.isalpha() for c in s)
|
|
69
|
+
if letters < 2 or len(_WORDY.findall(s)) / len(s) < 0.85:
|
|
70
|
+
continue
|
|
71
|
+
if re.fullmatch(r"(Text|Line|Legend|Graph|Layer)\d*", s):
|
|
72
|
+
continue # object names, not user text
|
|
73
|
+
if len(s) >= 4 or " " in s or "(" in s:
|
|
74
|
+
out.append(s)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_TITLE_OBJECT_BUCKETS = {"YL": "y_title", "XB": "x_title", "YR": "y2_title", "Legend": "legend"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _object_bucket(name: str | None) -> str:
|
|
82
|
+
"""Which recovered-text bucket a graph-child object's own name routes to
|
|
83
|
+
(see ``figures.py``): ``YL``/``XB``/``YR`` are the Y/X/secondary-Y axis
|
|
84
|
+
titles, ``Legend`` is the per-curve legend text, ``Text*``/``Line*`` are
|
|
85
|
+
genuine floating annotations. The legend object's name is matched
|
|
86
|
+
case-insensitively: composite (multi-layer) graph windows name it
|
|
87
|
+
lowercase ``legend`` (20 corpus instances across PNR/Moke/
|
|
88
|
+
MnN_Diffusion_PNR/SLD_DoubleY.otp, 2026-07-11 sweep — always the dotted
|
|
89
|
+
``\\l(layer.plot)`` legend of a double-Y/merge window), which the old
|
|
90
|
+
exact match silently routed to "ignore", dropping the whole legend.
|
|
91
|
+
Everything else — ``XT`` (the rarely-used top-X axis), internal
|
|
92
|
+
storage/config objects (``__LayerInfoStorage``, ``__BCO2``,
|
|
93
|
+
``__FRAMESRCDATAINFOS``, ``3D``), composite-layout axis-break
|
|
94
|
+
sub-objects (``OB``/``OL``/``OR``/``X1``/``X2``), reference lines
|
|
95
|
+
(``RLX*``/``RLY*``), or an unresolved name — is deliberately routed to
|
|
96
|
+
``"ignore"``: dropped, never guessed into the wrong bucket. (Region
|
|
97
|
+
shapes, ``Rect*``, are no longer a text concern at all — their typed
|
|
98
|
+
headers (0x31) are decoded by ``opj_shapes`` before name routing runs.)
|
|
99
|
+
"""
|
|
100
|
+
if name is None:
|
|
101
|
+
return "ignore"
|
|
102
|
+
if name in _TITLE_OBJECT_BUCKETS:
|
|
103
|
+
return _TITLE_OBJECT_BUCKETS[name]
|
|
104
|
+
if name.lower() == "legend":
|
|
105
|
+
return "legend"
|
|
106
|
+
if name.startswith("Text") or name.startswith("Line"):
|
|
107
|
+
return "annotations"
|
|
108
|
+
return "ignore"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _first_title(texts: Sequence[str]) -> str:
|
|
112
|
+
"""The first non-empty recovered string for a single-valued title bucket
|
|
113
|
+
(``x_title``/``y_title``/``y2_title``) — these objects hold exactly one
|
|
114
|
+
string in every corpus instance seen; ``""`` when the bucket never
|
|
115
|
+
resolved (an untouched auto-title template like ``%(?Y)`` is already
|
|
116
|
+
filtered out upstream by ``_texts_in``'s letter-count check). The chosen
|
|
117
|
+
string is decoded from Origin rich-text (``\\g(q)`` → θ, ``\\(40)`` → the
|
|
118
|
+
char, super/subscripts, styling stripped) so it displays as a plain title."""
|
|
119
|
+
for t in texts:
|
|
120
|
+
if t.strip():
|
|
121
|
+
return clean_richtext(t)
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_LEGEND_LINE_RE = re.compile(r"\\l\((\d+)\)\s*(.*)")
|
|
126
|
+
# A single legend LINE can hold several entries back-to-back with no newline
|
|
127
|
+
# between them (Origin only needs the \l(n) swatch marker, not a line break —
|
|
128
|
+
# seen live in Hc2 data.opju Graph3: ``\l(4) Nb\l(5) Nb/Al\l(6) Nb/Au``), so
|
|
129
|
+
# split each line into per-entry segments at every \l(n) boundary first.
|
|
130
|
+
# The boundary also matches the composite (multi-layer) dotted form
|
|
131
|
+
# ``\l(layer.plot)`` so `_parse_legend_layers` sees clean segments; the
|
|
132
|
+
# plain `_LEGEND_LINE_RE` above still ignores dotted entries by design.
|
|
133
|
+
_LEGEND_SEG_RE = re.compile(r"(?=\\l\(\d+(?:\.\d+)?\))")
|
|
134
|
+
# Composite (multi-layer) legend entry: ``\l(layer.plot) <label>`` — the
|
|
135
|
+
# `layer.plot` indexing Origin uses whenever ONE legend object captions a
|
|
136
|
+
# multi-layer window's curves (double-Y overlays, merge windows). See
|
|
137
|
+
# `_parse_legend_layers`.
|
|
138
|
+
_LEGEND_DOTTED_RE = re.compile(r"\\l\((\d+)\.(\d+)\)\s*(.*)")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _iter_legend_entries(texts: Sequence[str]) -> Iterator[tuple[int | None, int, str]]:
|
|
142
|
+
"""The ONE grammar walk over legend text (MAIN #8e): split every line
|
|
143
|
+
into per-entry segments at each ``\\l(...)`` swatch boundary
|
|
144
|
+
(``_LEGEND_SEG_RE``) and yield one ``(layer, plot, raw_label)`` triple
|
|
145
|
+
per entry — ``layer`` is the dotted ``\\l(layer.plot)`` layer index, or
|
|
146
|
+
``None`` for a plain ``\\l(n)`` entry. Labels are yielded RAW (no strip,
|
|
147
|
+
no ``%(...)`` re-indexing, no ``clean_richtext``) because the two
|
|
148
|
+
consumers below apply different label semantics — and, deliberately,
|
|
149
|
+
neither parser is the other's projection: on a text mixing ``\\l(1.1)``
|
|
150
|
+
and ``\\l(2)`` entries, layer 1 of `_parse_legend_layers` holds BOTH
|
|
151
|
+
while `_parse_legend_labels` keeps ignoring the dotted entry by design.
|
|
152
|
+
The two entry regexes are mutually exclusive (``.`` vs ``)`` after the
|
|
153
|
+
first digit run), so trying the dotted form first can never shadow a
|
|
154
|
+
plain entry."""
|
|
155
|
+
for t in texts:
|
|
156
|
+
for seg in _LEGEND_SEG_RE.split(t):
|
|
157
|
+
m = _LEGEND_DOTTED_RE.match(seg)
|
|
158
|
+
if m:
|
|
159
|
+
yield int(m.group(1)), int(m.group(2)), m.group(3)
|
|
160
|
+
continue
|
|
161
|
+
m2 = _LEGEND_LINE_RE.match(seg)
|
|
162
|
+
if m2:
|
|
163
|
+
yield None, int(m2.group(1)), m2.group(2)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_legend_labels(texts: Sequence[str]) -> list[str]:
|
|
167
|
+
"""Per-curve legend captions from the Legend object's own
|
|
168
|
+
``\\l(n) <label>`` entries. ``label`` is kept verbatim — whether
|
|
169
|
+
hand-edited literal text (e.g. hc2convert's ``"Nb"``/``"Nb/Al"``/
|
|
170
|
+
``"Nb/Au"``, XRD's ``"325"``/``"525"``) or the untouched auto template
|
|
171
|
+
(``"%(2)"``) — never resolved further. Returns a dense 1-based list sized
|
|
172
|
+
to the highest ``n`` seen, with ``""`` for any gap; ``[]`` when no
|
|
173
|
+
``\\l(n)`` entry was found. Never fabricated: a missing slot stays blank
|
|
174
|
+
rather than guessed. Dotted ``\\l(layer.plot)`` entries are ignored by
|
|
175
|
+
design (see `_iter_legend_entries` — this is NOT the layer-1 projection
|
|
176
|
+
of `_parse_legend_layers`).
|
|
177
|
+
"""
|
|
178
|
+
labels: dict[int, str] = {}
|
|
179
|
+
for layer, plot, raw in _iter_legend_entries(texts):
|
|
180
|
+
if layer is None:
|
|
181
|
+
labels[plot] = clean_richtext(raw.strip())
|
|
182
|
+
if not labels:
|
|
183
|
+
return []
|
|
184
|
+
return [labels.get(i, "") for i in range(1, max(labels) + 1)]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_legend_layers(texts: Sequence[str]) -> dict[int, list[str]]:
|
|
188
|
+
"""Per-LAYER legend captions from a composite legend's dotted
|
|
189
|
+
``\\l(layer.plot) <label>`` entries (item 41 — the multi-layer legend
|
|
190
|
+
object of a double-Y/merge window, e.g. PNR.opj Graph1's
|
|
191
|
+
``\\l(1.1) %(1.1) \\l(2.1) %(2.1) …``). Returns ``{layer: dense
|
|
192
|
+
1-based label list}``; plain ``\\l(n)`` entries land in layer 1 (a
|
|
193
|
+
single-layer legend's implied layer). An auto template referencing the
|
|
194
|
+
SAME dotted slot (``%(layer.plot)``) is re-indexed to the target
|
|
195
|
+
layer's own curve ordinal (``%(plot)``) so the per-figure ``%(n)``
|
|
196
|
+
resolver applies unchanged; a template referencing a DIFFERENT layer is
|
|
197
|
+
left verbatim — a raw code is better than a wrong guess. Missing slots
|
|
198
|
+
stay ``""``, never fabricated."""
|
|
199
|
+
per: dict[int, dict[int, str]] = {}
|
|
200
|
+
for layer, plot, raw in _iter_legend_entries(texts):
|
|
201
|
+
if layer is None:
|
|
202
|
+
per.setdefault(1, {})[plot] = clean_richtext(raw.strip())
|
|
203
|
+
continue
|
|
204
|
+
label = re.sub(rf"%\({layer}\.(\d+)\)", r"%(\1)", raw.strip())
|
|
205
|
+
per.setdefault(layer, {})[plot] = clean_richtext(label)
|
|
206
|
+
return {lyr: [d.get(i, "") for i in range(1, max(d) + 1)] for lyr, d in per.items() if d}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def distribute_legend_layers(figures: list[dict[str, Any]]) -> None:
|
|
210
|
+
"""Window-level composite-legend pass (item 41): one graph window's
|
|
211
|
+
figure dicts (one per layer, each carrying its private ``_legend_raw``
|
|
212
|
+
lines) share ONE legend object, but its dotted ``\\l(layer.plot)``
|
|
213
|
+
entries caption OTHER layers' curves — the per-layer span parse alone
|
|
214
|
+
leaves every layer's ``legend_labels`` empty. Pops ``_legend_raw`` from
|
|
215
|
+
every dict (the private key never ships), and — only when a dotted
|
|
216
|
+
entry exists anywhere in the window — fills each layer's EMPTY
|
|
217
|
+
``legend_labels`` from the layered parse. A layer whose own plain
|
|
218
|
+
``\\l(n)`` legend already parsed is never overwritten."""
|
|
219
|
+
raw: list[str] = []
|
|
220
|
+
for f in figures:
|
|
221
|
+
raw.extend(f.pop("_legend_raw", []))
|
|
222
|
+
if all(layer is None for layer, _, _ in _iter_legend_entries(raw)):
|
|
223
|
+
return # no dotted entry anywhere in the window
|
|
224
|
+
layered = _parse_legend_layers(raw)
|
|
225
|
+
for f in figures:
|
|
226
|
+
if not f.get("legend_labels"):
|
|
227
|
+
entries = layered.get(int(f.get("layer", 1)))
|
|
228
|
+
if entries:
|
|
229
|
+
f["legend_labels"] = entries
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _object_text(payload: bytes) -> str | None:
|
|
233
|
+
"""The exact text of an annotation object's CONTENT block: the entire
|
|
234
|
+
block is ``<text>\x00`` (observed structurally: a Text object = 133-byte
|
|
235
|
+
header, a 103-byte format block, then a content block holding ONLY the
|
|
236
|
+
NUL-terminated string — e.g. ``b"X\x00"`` for a one-char peak marker).
|
|
237
|
+
Returns ``None`` unless the block is exactly that shape (non-empty,
|
|
238
|
+
single trailing NUL, no interior NULs/controls beyond CR/LF/TAB) — so
|
|
239
|
+
format/geometry blocks can never read as text. Solved 2026-07-06: the
|
|
240
|
+
heuristic ``_texts_in`` scan needs >=3 printable chars and wordiness,
|
|
241
|
+
silently dropping Origin's ultra-short peak labels ('X', '*', 'Si');
|
|
242
|
+
ownership by a named Text header is the trust signal that replaces
|
|
243
|
+
those noise filters."""
|
|
244
|
+
# 4096-byte cap: a floating-text annotation can hold a paragraph (the old
|
|
245
|
+
# 512 silently dropped long notes); still bounded so a large format/geometry
|
|
246
|
+
# block can't be mistaken for text (2026-07-06 genericity audit).
|
|
247
|
+
if not 2 <= len(payload) <= 4096 or payload[-1] != 0:
|
|
248
|
+
return None
|
|
249
|
+
body = payload[:-1]
|
|
250
|
+
if not body or any(b < 0x20 and b not in (0x09, 0x0A, 0x0D) for b in body) or 0 in body:
|
|
251
|
+
return None
|
|
252
|
+
# UTF-8-first (the .opju container's text encoding — degree/Greek/micro);
|
|
253
|
+
# latin-1 fallback for the rare cell that is latin-1 but not valid UTF-8.
|
|
254
|
+
try:
|
|
255
|
+
text = body.decode("utf-8").replace("\r\n", "\n").strip()
|
|
256
|
+
except UnicodeDecodeError:
|
|
257
|
+
text = body.decode("latin1").replace("\r\n", "\n").strip()
|
|
258
|
+
return text or None
|