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.
Files changed (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. 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