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,245 @@
1
+ """``.opju`` (CPYUA) graph text-object recovery — axis titles / legend / annotations.
2
+
3
+ Solved 2026-07-05 (previously a documented limitation: ``figures_opju`` dumped
4
+ every recovered string into one flat ``annotations`` list). CPYUA graph pages
5
+ carry the SAME named child objects the ``.opj`` container has (``YL``/``XB``/
6
+ ``YR``/``XT`` axis titles, ``Legend``, ``Text``/``TextN`` floating
7
+ annotations, ``__FRAMESRCDATAINFOS`` internals …), just in a different
8
+ framing. Two byte-level structures, validated across every graph page of the
9
+ 5-file real ``.opju`` corpus (323 name→text pairs, 0 orphan texts, and — the
10
+ strongest check — every shared graph's routed titles equal to the
11
+ ``hc2convert.opj`` Save-As conversion's independently decoded titles):
12
+
13
+ **Object-name header** — a tagged field whose payload opens ``0x10``,
14
+ immediately followed by the name field::
15
+
16
+ <tag 80-9f> 04 10 00 00 <xx> <ntag> <nlen> <name> (axis-title shape)
17
+ <tag 80-9f> 01 10 <ntag> <nlen> <name> (Legend/Text shape)
18
+
19
+ ``tag`` varies (``8a``/``84``/``92`` observed), ``<xx>`` varies (``02``/
20
+ ``04``/``82``/``84`` — uninterpreted), ``ntag`` is ``0x80`` or ``0x83``, and
21
+ ``name`` is plain ASCII (min 2 chars — a 1-char floor admitted junk
22
+ single-letter "names" in trailing storage sections).
23
+
24
+ **Text content** — a 1-byte ``0x80`` field then a length-prefixed string::
25
+
26
+ <tag> 01 80 <tlen> <text … 00>
27
+
28
+ ``tag`` is ``0x86`` or ``0xa8`` (both observed; a corpus scan accepting ANY
29
+ tag byte found zero additional matches that survive validation, so the
30
+ ``0x80-0xbf`` tag range accepted here is not load-bearing — the validation
31
+ is: ``tlen`` exact, NUL terminator present, strict UTF-8, no control bytes
32
+ beyond CR/LF/TAB). Text is UTF-8 (e.g. Hc2's ``H\\-(c2⊥) (T)`` title and
33
+ ``∥``/``⊥`` legend labels are raw multi-byte characters here, where the
34
+ ANSI ``.opj`` container stores ``\\(x22A5)`` escapes instead).
35
+
36
+ **Pairing** — objects are sequential: name header, a short style/format
37
+ field run (name→text distance 49-66 bytes across the whole corpus; bounded
38
+ at ``_MAX_NAME_TO_TEXT`` = 512 to allow longer style runs while still
39
+ failing closed), then the object's single text. Each text run therefore
40
+ pairs with the nearest preceding *unconsumed* header; a text with no such
41
+ header defaults to ``annotations`` (the same degrade ``.opj``'s
42
+ ``_build_layer`` uses for text before its first resolvable header) — so a
43
+ regex-missed header can only ever yield a missing/annotation-demoted title,
44
+ never a WRONG one. Routing reuses ``figures.py``'s ``_object_bucket`` table
45
+ verbatim, and the title/legend/annotation cleanup runs through the exact
46
+ same pipeline (``_first_title``/``_parse_legend_labels``/
47
+ ``_clean_annotations`` + ``clean_richtext``) so both containers' outputs
48
+ match character-for-character.
49
+
50
+ Undecoded/known negatives: an object holding MORE than one framed text run
51
+ would send its second run to ``annotations`` (never observed in the
52
+ corpus); a text longer than 255 bytes presumably uses a wider length
53
+ encoding never observed — such a run fails the NUL check and is dropped,
54
+ not truncated.
55
+
56
+ Pure library: bytes in → strings out. No fastapi/pydantic/routes imports.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import re
62
+ from typing import Any, NamedTuple
63
+
64
+ from quantized.io.origin_project.annotation_marks import (
65
+ _AUTO_TITLE,
66
+ _clean_annotations,
67
+ build_mark,
68
+ frac_to_data,
69
+ opju_text_fractions,
70
+ )
71
+ from quantized.io.origin_project.figure_text import (
72
+ _first_title,
73
+ _object_bucket,
74
+ _parse_legend_labels,
75
+ )
76
+ from quantized.io.origin_project.origin_richtext import clean_richtext
77
+
78
+ __all__ = ["FigureText", "routed_figure_text"]
79
+
80
+ # Object-name header: <tag> <plen 01|04> <payload[0]=0x10 ...> (module docstring).
81
+ _HDR_RE = re.compile(rb"[\x80-\x9f]([\x01\x04])\x10", re.DOTALL)
82
+ _NAME_TAGS = (0x80, 0x83)
83
+ _NAME_RE = re.compile(rb"[A-Za-z_][A-Za-z0-9_]{1,23}\Z") # >= 2 chars (docstring)
84
+
85
+ # Text content field: <tag> 01 80 <tlen> <text ... 00> (module docstring).
86
+ _TEXT_RE = re.compile(rb"[\x80-\xbf]\x01\x80", re.DOTALL)
87
+
88
+ # Upper bound on the style/format run between an object's name header and its
89
+ # own text (measured max 66 bytes corpus-wide; generous margin, still local).
90
+ _MAX_NAME_TO_TEXT = 512
91
+
92
+
93
+ class FigureText(NamedTuple):
94
+ """One graph layer's routed text, shaped exactly like the ``.opj`` fields."""
95
+
96
+ x_title: str
97
+ y_title: str
98
+ y2_title: str
99
+ legend_labels: list[str]
100
+ annotations: list[str]
101
+ # Positioned floating text ({"text", "x", "y"} in data coords, one per
102
+ # Text object, multi-line preserved) — see annotation_marks.py. Empty
103
+ # when the caller supplied no axis range or no position field decoded.
104
+ annotation_marks: list[dict[str, Any]]
105
+ # The Legend object's box top-left in data coords ({"x", "y"}) — the
106
+ # SAME position field every text object carries (§13.2 #3, 2026-07-06),
107
+ # read from the Legend name header. None when no legend, no axes, or
108
+ # the position field didn't decode (omitted, never guessed).
109
+ legend_pos: dict[str, float] | None
110
+
111
+
112
+ def _object_headers(b: bytes, start: int, end: int) -> list[tuple[int, str]]:
113
+ """Every object-name header in ``b[start:end)`` as ``(position, name)``."""
114
+ out: list[tuple[int, str]] = []
115
+ for m in _HDR_RE.finditer(b, start, end):
116
+ plen = m.group(1)[0]
117
+ if plen == 4 and b[m.start() + 3 : m.start() + 5] != b"\x00\x00":
118
+ continue # axis-title shape's payload is always 10 00 00 <xx>
119
+ p = m.start() + 2 + plen # the name field, right after the payload
120
+ if p + 2 > end or b[p] not in _NAME_TAGS:
121
+ continue
122
+ nlen = b[p + 1]
123
+ name = b[p + 2 : p + 2 + nlen]
124
+ if len(name) != nlen or not _NAME_RE.match(name):
125
+ continue
126
+ out.append((m.start(), name.decode("ascii")))
127
+ return out
128
+
129
+
130
+ def _text_runs(b: bytes, start: int, end: int) -> list[tuple[int, str]]:
131
+ """Every framed text run in ``b[start:end)`` as ``(position, text)``.
132
+
133
+ Validation is what carries the safety here (see module docstring): the
134
+ length byte must fit, the run must be NUL-terminated, decode as strict
135
+ UTF-8, and contain no control characters beyond CR/LF/TAB — anything
136
+ else is dropped, never repaired.
137
+ """
138
+ out: list[tuple[int, str]] = []
139
+ for m in _TEXT_RE.finditer(b, start, end):
140
+ p = m.end()
141
+ if p >= end:
142
+ continue
143
+ tlen = b[p]
144
+ raw = b[p + 1 : p + 1 + tlen]
145
+ if len(raw) != tlen or tlen < 2 or raw[-1] != 0:
146
+ continue
147
+ try:
148
+ body = raw[:-1].decode("utf-8")
149
+ except UnicodeDecodeError:
150
+ continue
151
+ if any(ord(c) < 0x20 and c not in "\r\n\t" for c in body):
152
+ continue
153
+ out.append((m.start(), body))
154
+ return out
155
+
156
+
157
+ def routed_figure_text(
158
+ b: bytes,
159
+ start: int,
160
+ end: int,
161
+ axes: tuple[float, float, float, float] | None = None,
162
+ x_log: bool = False,
163
+ y_log: bool = False,
164
+ ) -> FigureText | None:
165
+ """Route one figure window's text objects into the ``.opj``-shaped buckets.
166
+
167
+ Returns ``None`` when the window holds no framed text run at all (legacy/
168
+ synthetic streams without CPYUA text objects) so the caller can degrade
169
+ to its historical flat-scrape ``annotations``. Otherwise: each text run
170
+ pairs with the nearest preceding unconsumed name header within
171
+ ``_MAX_NAME_TO_TEXT`` bytes and routes via ``figures._object_bucket``
172
+ (``YL``→``y_title``, ``XB``→``x_title``, ``YR``→``y2_title``,
173
+ ``Legend``→legend, ``Text*``/``Line*``→annotations, anything else →
174
+ dropped); an unpaired text defaults to ``annotations``. Multi-line runs
175
+ (CPYUA stores a whole legend/textbox as ONE ``\\r\\n``-joined string,
176
+ where ``.opj``'s byte scan naturally splits at the control bytes) are
177
+ split into lines first, so both containers feed identical strings into
178
+ the shared cleanup pipeline.
179
+
180
+ ``axes`` is the layer's ``(x_from, x_to, y_from, y_to)``; when given,
181
+ every annotation-bucket text whose own name header carries the
182
+ fixed-distance position field (``annotation_marks.opju_text_fractions``)
183
+ also emits a positioned ``annotation_marks`` entry — the whole
184
+ multi-line object as ONE mark. Objects without the field (or an
185
+ unpaired text, which has no header to read from) stay text-only.
186
+ """
187
+ texts = _text_runs(b, start, end)
188
+ if not texts:
189
+ return None
190
+ headers = _object_headers(b, start, end)
191
+ buckets: dict[str, list[str]] = {
192
+ "x_title": [],
193
+ "y_title": [],
194
+ "y2_title": [],
195
+ "legend": [],
196
+ "annotations": [],
197
+ }
198
+ marks: list[dict[str, Any]] = []
199
+ legend_pos: dict[str, float] | None = None
200
+ events = sorted(
201
+ [(pos, 0, name) for pos, name in headers] + [(pos, 1, text) for pos, text in texts]
202
+ )
203
+ pending: tuple[int, str] | None = None
204
+ for pos, kind, value in events:
205
+ if kind == 0: # a name header: becomes the pending object (one text each)
206
+ pending = (pos, value)
207
+ continue
208
+ header_pos: int | None = None
209
+ if pending is not None and pos - pending[0] <= _MAX_NAME_TO_TEXT:
210
+ bucket = _object_bucket(pending[1])
211
+ header_pos = pending[0]
212
+ else:
213
+ bucket = "annotations" # unpaired text: same default bucket .opj uses
214
+ pending = None
215
+ if bucket not in buckets:
216
+ continue
217
+ lines = [line for line in re.split(r"\r\n|[\r\n]", value) if line.strip()]
218
+ buckets[bucket].extend(lines)
219
+ if bucket == "annotations" and header_pos is not None and axes is not None:
220
+ mark = build_mark(
221
+ opju_text_fractions(b, header_pos), lines, *axes, x_log, y_log
222
+ )
223
+ if mark is not None:
224
+ marks.append(mark)
225
+ if bucket == "legend" and header_pos is not None and axes is not None:
226
+ fracs = opju_text_fractions(b, header_pos)
227
+ if fracs is not None and legend_pos is None:
228
+ lx, ly = frac_to_data(fracs[0], fracs[1], *axes, x_log, y_log)
229
+ legend_pos = {"x": lx, "y": ly}
230
+
231
+ def _title(lines: list[str]) -> str:
232
+ # .opj's _texts_in drops the %(?X)/%(?Y) auto-templates via its letter
233
+ # filter; the framed extraction sees them verbatim, so filter here.
234
+ return _first_title([t for t in lines if not _AUTO_TITLE.match(t)])
235
+
236
+ notes = [t for t in buckets["annotations"] if not _AUTO_TITLE.match(t) and "\\l(" not in t]
237
+ return FigureText(
238
+ x_title=_title(buckets["x_title"]),
239
+ y_title=_title(buckets["y_title"]),
240
+ y2_title=_title(buckets["y2_title"]),
241
+ legend_labels=_parse_legend_labels(buckets["legend"]),
242
+ annotations=[clean_richtext(a) for a in _clean_annotations(notes)[:12]],
243
+ annotation_marks=marks,
244
+ legend_pos=legend_pos,
245
+ )
@@ -0,0 +1,129 @@
1
+ """Origin auto-generated report-sheet columns for ``.opju`` (CPYUA) — plan
2
+ item 4's report-sheet residue, the ``.opju`` sibling of ``opj.py``'s
3
+ ``decode_report_strings`` (``container.py``).
4
+
5
+ Origin's FitLinear/NLFit X-Functions auto-generate report sheets ("FitNL1",
6
+ "FitLinearCurve1", …) whose cells hold a variable-length
7
+ ``cell://<Section>.<Row>.<Field>`` reference string (e.g.
8
+ ``"cell://Parameters.Slope.Value"``, naming *which* fit statistic that cell
9
+ represents) rather than a plain float64. ``.opj``'s version of this family
10
+ overflows a fixed 8-byte value area (``container.decode_inline_text`` has to
11
+ drop it); ``.opju`` stores the SAME conceptual content in its own record
12
+ framing, sharing ``opju_codec``'s ``0a 05 <…> ff ff <…>`` marker but taking a
13
+ completely different branch after it.
14
+
15
+ **The grammar** (pinned against ``specimens/fitreport2.opju`` — a known
16
+ linear fit, x=1..8, slope -1.5, intercept 9.5 — whose ``FitNL1`` report sheet
17
+ has 28 columns, ``FitNLCurve1`` has 11): a normal numeric record is ``ff ff
18
+ <varint> 00 <ZigZag-varint segments>`` (``opju_codec``'s grammar: the byte
19
+ right after the row-count varint is ``0x00``, then the segment list decodes
20
+ straight to float64s). A REPORT column instead has ``0x01`` at that exact
21
+ position — the discriminator, checked at the same byte offset the numeric
22
+ codec already tests. What follows is *not* ``opju_codec``'s FPC/repeat
23
+ segment grammar at all: a single ZigZag-varint segment count, then, if
24
+ negative (``-m``), ``m`` consecutive ``<len:u8><ASCII bytes>`` strings
25
+ (``len=0`` is a valid, blank report cell — most report columns hold only a
26
+ handful of populated rows out of the segment's ``m``). A positive segment
27
+ count was observed on 2 of ``FitNL1``'s 28 columns (its first two, with no
28
+ ``cell://`` content at all) and its shape is not understood — those columns
29
+ are honestly dropped, never guessed at.
30
+
31
+ **Validation:** every one of ``FitNL1``'s 26 populated columns decodes
32
+ cleanly (the reference strings match exactly what
33
+ ``tools/origin_trial/generate_specimens3.py``'s ``fitreport2`` generator
34
+ produced — ``Notes.*`` metadata, ``Input.R1/R2.C1..C4``,
35
+ ``Parameters.A/B/xintercept.{Value,Error,tValue,Prob,Dependency}``,
36
+ ``RegStats.C1.*``, ``Summary.R1.*``, ``ANOVAs.*``); the 2 non-text columns
37
+ are honestly dropped. **What this does NOT recover:** the fit's actual
38
+ computed numbers (e.g. Slope = -1.5) are not present as a plain float64
39
+ anywhere near these records (checked directly: neither raw nor FPC-compact
40
+ encodings of 9.5/-1.5 appear in the report-sheet byte range) — Origin
41
+ appears to cache them in a separate internal structure this module does not
42
+ decode. See ``docs/origin_project_format.md`` "Non-double column values" for
43
+ the full writeup and the byte-level trail.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from quantized.io.origin_project.opju_codec import (
49
+ _NAME,
50
+ CodecError,
51
+ _read_varint,
52
+ _records,
53
+ _zigzag,
54
+ )
55
+
56
+ __all__ = ["scan_report_columns"]
57
+
58
+ _PRINTABLE = frozenset(range(0x20, 0x7F))
59
+ _MAX_ROWS = 500 # generous ceiling for a report sheet's row count (real corpus: <= 11)
60
+ _MAX_STRLEN = 200 # generous ceiling for one reference string (real corpus: <= 34 chars)
61
+
62
+
63
+ def _decode_report_record(b: bytes, marker: int) -> list[str] | None:
64
+ """Decode one report-column record at a ``ff ff`` marker (``marker`` is
65
+ the byte offset of the first ``0xff``), or ``None`` if it isn't this
66
+ shape or fails validation.
67
+
68
+ Shares the row-count varint read with ``opju_codec._decode_record`` but
69
+ diverges the instant the following byte is ``0x01`` instead of ``0x00``
70
+ — the two codecs are mutually exclusive by construction (gated on the
71
+ same byte), so this never intercepts a record ``opju_codec.scan_columns``
72
+ would otherwise decode.
73
+ """
74
+ try:
75
+ _row_count, p = _read_varint(b, marker + 2)
76
+ except CodecError:
77
+ return None
78
+ if p >= len(b) or b[p] != 0x01: # 0x00 = a plain numeric record, not ours
79
+ return None
80
+ p += 1
81
+ try:
82
+ raw, p = _read_varint(b, p)
83
+ except CodecError:
84
+ return None
85
+ count = _zigzag(raw)
86
+ if count >= 0:
87
+ return None # a positive/blank-fill segment -- shape not understood, honest drop
88
+ m = -count
89
+ if not 1 <= m <= _MAX_ROWS:
90
+ return None
91
+ rows: list[str] = []
92
+ for _ in range(m):
93
+ if p >= len(b):
94
+ return None
95
+ length = b[p]
96
+ p += 1
97
+ if length > _MAX_STRLEN or p + length > len(b):
98
+ return None
99
+ raw_bytes = b[p : p + length]
100
+ p += length
101
+ if not all(c in _PRINTABLE for c in raw_bytes):
102
+ return None
103
+ rows.append(raw_bytes.decode("latin1"))
104
+ return rows
105
+
106
+
107
+ def scan_report_columns(b: bytes) -> list[tuple[str, list[str]]]:
108
+ """Every ``<Book>_<Col>[@sheet]`` report-sheet column in a CPYUA file.
109
+
110
+ Reuses ``opju_codec``'s record-marker scan (``_records``) and the same
111
+ "nearest preceding length-prefixed dataset name" anchoring rule
112
+ ``scan_columns`` uses; the two functions never compete for the same
113
+ marker (see ``_decode_report_record``'s docstring), so this can run as an
114
+ entirely independent second pass with no cursor coordination needed.
115
+ """
116
+ names = [
117
+ (m.start(), m.group(0).decode("latin1"))
118
+ for m in _NAME.finditer(b)
119
+ if m.start() > 0 and b[m.start() - 1] == len(m.group(0))
120
+ ]
121
+ out: list[tuple[str, list[str]]] = []
122
+ for marker in _records(b):
123
+ rows = _decode_report_record(b, marker)
124
+ if rows is None:
125
+ continue
126
+ prev = [name for pos, name in names if pos < marker]
127
+ if prev:
128
+ out.append((prev[-1], rows))
129
+ return out
@@ -0,0 +1,145 @@
1
+ """Origin LabTalk rich-text (escape-code) → plain display text.
2
+
3
+ Origin stores axis titles, legend labels, and text annotations with inline
4
+ formatting escapes (LabTalk "text object" syntax). Extracting the raw bytes
5
+ (``figures.py``) recovers the string *with* those escapes, e.g. an XRD x-axis
6
+ title comes back as ``2\\g(q \\(40))degrees)`` — which, shown verbatim, reads
7
+ as literal backslash noise rather than "2θ (degrees)". This module decodes the
8
+ display-affecting escapes so a recreated plot's labels match Origin.
9
+
10
+ Handled escapes:
11
+ ``\\g(...)`` Symbol-font run → Greek letters (q→θ, a→α, m→μ, …)
12
+ ``\\(NNN)`` insert the character with decimal code NNN
13
+ ``\\(xHHHH)`` insert the character with hex code HHHH — Origin's
14
+ Unicode form (how a ``.opj``-container Save-As stores
15
+ non-ANSI characters, e.g. ``\\(x2225)`` → ∥; observed
16
+ live in hc2convert.opj's converted axis titles)
17
+ ``\\+(...)`` / ``\\-(...)`` super-/sub-script → Unicode super/subscripts
18
+ ``\\b(...)`` ``\\i(...)`` ``\\u(...)`` ``\\f:Font(...)`` ``\\c<n>(...)``
19
+ bold/italic/underline/font/colour → keep inner text
20
+
21
+ Left untouched: ``%(...)`` data-reference substitutions (e.g. a legend's
22
+ auto-label ``%(2)`` means "dataset 2's name" — a reference, not display text)
23
+ and any shape we don't recognise (we degrade to the raw string, never worse).
24
+
25
+ Pure library: str in → str out. No project imports.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ # Adobe/Origin "Symbol" font: Latin code point → the Greek glyph it displays.
31
+ _SYMBOL_GREEK: dict[str, str] = {
32
+ "a": "α", "b": "β", "g": "γ", "d": "δ", "e": "ε", "z": "ζ", "h": "η",
33
+ "q": "θ", "i": "ι", "k": "κ", "l": "λ", "m": "μ", "n": "ν", "x": "ξ",
34
+ "o": "ο", "p": "π", "r": "ρ", "s": "σ", "t": "τ", "u": "υ", "f": "φ",
35
+ "c": "χ", "y": "ψ", "w": "ω", "v": "ς", "j": "ϕ",
36
+ "A": "Α", "B": "Β", "G": "Γ", "D": "Δ", "E": "Ε", "Z": "Ζ", "H": "Η",
37
+ "Q": "Θ", "I": "Ι", "K": "Κ", "L": "Λ", "M": "Μ", "N": "Ν", "X": "Ξ",
38
+ "O": "Ο", "P": "Π", "R": "Ρ", "S": "Σ", "T": "Τ", "U": "Υ", "F": "Φ",
39
+ "C": "Χ", "Y": "Ψ", "W": "Ω",
40
+ } # fmt: skip
41
+ _GREEK_TABLE = {ord(k): v for k, v in _SYMBOL_GREEK.items()}
42
+ _SUP = str.maketrans("0123456789+-=()n", "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿ")
43
+ _SUB = str.maketrans("0123456789+-=()", "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎")
44
+
45
+
46
+ def _codepoint(code: str) -> int | None:
47
+ """The character code of a ``\\(...)`` escape body: decimal (``40``) or
48
+ hex with an ``x`` prefix (``x2225`` — Origin's Unicode form, see the
49
+ module docstring). ``None`` when the body is neither shape, or falls
50
+ outside Unicode's assignable range (malformed → literal-paren fallback).
51
+ """
52
+ if code.isdigit():
53
+ value = int(code)
54
+ elif code[:1] in ("x", "X") and len(code) > 1:
55
+ try:
56
+ value = int(code[1:], 16)
57
+ except ValueError:
58
+ return None
59
+ else:
60
+ return None
61
+ return value if 0 <= value <= 0x10FFFF and not 0xD800 <= value <= 0xDFFF else None
62
+
63
+
64
+ def _apply_run(control: str, inner: str) -> str:
65
+ """Render a ``\\<control>(inner)`` run's already-decoded inner text."""
66
+ if control == "g":
67
+ return inner.translate(_GREEK_TABLE)
68
+ if control == "+":
69
+ return inner.translate(_SUP)
70
+ if control == "-":
71
+ return inner.translate(_SUB)
72
+ # b / i / u / f:Font / c<n> / anything else: drop the styling, keep text.
73
+ return inner
74
+
75
+
76
+ def _render(s: str) -> str:
77
+ out: list[str] = []
78
+ i, n = 0, len(s)
79
+ while i < n:
80
+ c = s[i]
81
+ if c != "\\":
82
+ out.append(c)
83
+ i += 1
84
+ continue
85
+ # \(NNN) / \(xHHHH) — a character-code escape (atomic: its own parens).
86
+ if i + 1 < n and s[i + 1] == "(":
87
+ close = s.find(")", i + 2)
88
+ code = s[i + 2 : close] if close != -1 else ""
89
+ cp = _codepoint(code) if close != -1 else None
90
+ if cp is not None:
91
+ out.append(chr(cp))
92
+ i = close + 1
93
+ else:
94
+ out.append("(") # malformed — treat as a literal paren
95
+ i += 2
96
+ continue
97
+ # \<control>(...) — a formatting run; control is the text up to '('.
98
+ k = s.find("(", i + 1)
99
+ if k == -1: # dangling backslash-escape (e.g. a lone "\n"): drop the "\"
100
+ out.append(s[i + 1] if i + 1 < n else "")
101
+ i += 2
102
+ continue
103
+ control = s[i + 1 : k]
104
+ j, depth = k + 1, 1 # find the matching ')' of the run's opening '('
105
+ while j < n and depth:
106
+ if s[j] == "\\" and j + 1 < n:
107
+ if s[j + 1] == "(": # nested \(NNN) — skip the whole atom
108
+ inner_close = s.find(")", j + 2)
109
+ j = inner_close + 1 if inner_close != -1 else j + 2
110
+ continue
111
+ j += 2 # other escaped char
112
+ continue
113
+ if s[j] == "(":
114
+ depth += 1
115
+ elif s[j] == ")":
116
+ depth -= 1
117
+ if depth == 0:
118
+ break
119
+ j += 1
120
+ if depth != 0:
121
+ # Unterminated run — malformed; bail so clean_richtext() returns the
122
+ # raw string unchanged rather than Symbol-mapping the rest of the line.
123
+ raise ValueError("unterminated Origin rich-text run")
124
+ out.append(_apply_run(control, _render(s[k + 1 : j])))
125
+ i = j + 1
126
+ return "".join(out)
127
+
128
+
129
+ def clean_richtext(s: str) -> str:
130
+ """Decode Origin rich-text escapes in ``s`` to plain display text.
131
+
132
+ Idempotent-ish and total: strings with no ``\\`` escapes return unchanged,
133
+ and any parse error degrades to the raw input (never makes it worse).
134
+
135
+ >>> clean_richtext("2\\\\g(q \\\\(40))degrees)")
136
+ '2θ (degrees)'
137
+ >>> clean_richtext("Intensity (arb. units)")
138
+ 'Intensity (arb. units)'
139
+ """
140
+ if "\\" not in s:
141
+ return s
142
+ try:
143
+ return _render(s)
144
+ except Exception:
145
+ return s
@@ -0,0 +1,132 @@
1
+ """Row-decimated preview ``DataStruct`` for the lazy per-book import transport
2
+ (``ORIGIN_FILE_DECODE_PLAN`` #38).
3
+
4
+ Mirrors the frontend's min/max-per-bucket envelope-preserving downsample
5
+ (``frontend/src/lib/downsample.ts``, used for Library sparklines) but picks
6
+ whole ROWS -- every channel moves together -- instead of decimating one
7
+ channel's points independently. The result is a genuine (just smaller)
8
+ ``DataStruct``: any consumer that only reads ``.time``/``.values``/``.labels``/
9
+ ``.units``/``.metadata`` keeps working on it unchanged, which is the whole
10
+ point of the "pending dataset carries a real but preview-sized `data`" design
11
+ (see ``routes/parsers.py``'s ``_book_preview_payload``).
12
+
13
+ Pure layer -- no fastapi/pydantic imports (enforced by test_repo_integrity).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+
20
+ from quantized.datastruct import DataStruct
21
+
22
+ __all__ = ["decimate_datastruct"]
23
+
24
+
25
+ def _trim_trailing_padding(ds: DataStruct) -> DataStruct:
26
+ """Drop trailing rows that carry no real data, mirroring the frontend's
27
+ ``dropTrailingEmptyRows`` (``frontend/src/lib/plotdata.ts``). Origin's
28
+ over-allocated worksheet storage leaves "allocated but unfilled" rows at
29
+ the END of a book (verified byte-for-byte across ~10 PNR-corpus books,
30
+ e.g. Book15: 19 of 180 rows), in two shapes:
31
+
32
+ - not plottable at all -- ``.time`` is non-finite, or (when there are
33
+ value channels) every value in the row is also non-finite, or
34
+ - over-allocated-storage padding -- ``.time`` AND every value in the
35
+ row read exactly finite ``0.0`` simultaneously. This is a "point", not
36
+ a gap, so the rule above doesn't catch it, and left in, it resets the
37
+ independent axis back to 0 at the tail (breaking x-ascending) and
38
+ collapses a sparkline/preview built from the raw row order.
39
+
40
+ Only trims a contiguous run off the END; interior gaps are left in place.
41
+ Returns ``ds`` unchanged (same object) when there is no prunable tail.
42
+ """
43
+ n = ds.n_points
44
+ if n == 0:
45
+ return ds
46
+ time = ds.time
47
+ values = ds.values
48
+ has_y = ds.n_channels > 0
49
+
50
+ def plottable(i: int) -> bool:
51
+ if not np.isfinite(time[i]):
52
+ return False
53
+ if not has_y:
54
+ return True
55
+ return bool(np.any(np.isfinite(values[i, :])))
56
+
57
+ def all_zero_row(i: int) -> bool:
58
+ if not has_y:
59
+ return False
60
+ if time[i] != 0:
61
+ return False
62
+ return bool(np.all(values[i, :] == 0))
63
+
64
+ end = n
65
+ while end > 0 and (not plottable(end - 1) or all_zero_row(end - 1)):
66
+ end -= 1
67
+ if end == n:
68
+ return ds
69
+ return DataStruct(
70
+ time=ds.time[:end],
71
+ values=ds.values[:end, :],
72
+ labels=ds.labels,
73
+ units=ds.units,
74
+ metadata=ds.metadata,
75
+ )
76
+
77
+
78
+ def decimate_datastruct(ds: DataStruct, target_points: int = 200) -> DataStruct:
79
+ """Row-decimate ``ds`` to about ``target_points`` rows.
80
+
81
+ First prunes a trailing "allocated but unfilled" padding run (see
82
+ :func:`_trim_trailing_padding`) so a pending dataset's preview never
83
+ carries the padding into the thumbnail -- the fix for a preview whose
84
+ sparkline collapsed toward (0, 0) instead of tracing the real curve.
85
+
86
+ Then buckets the (pruned) row range into ``target_points // 2``
87
+ contiguous spans and keeps, from each span, the row holding the MINIMUM
88
+ and the row holding the MAXIMUM value of the densest channel (most finite
89
+ values) -- so a sparkline built from the result still shows real
90
+ spikes/dips a plain stride sample would step over. Every retained row
91
+ keeps ALL of its channels (they were picked together), so the output is a
92
+ normal, if smaller, ``DataStruct``.
93
+
94
+ Returns the (possibly padding-trimmed) ``ds`` unchanged when it already
95
+ has at most ``target_points`` rows or has no channels at all (an
96
+ empty-data pseudo-book -- nothing to pick extrema from, and nothing to
97
+ save by decimating zero columns).
98
+ """
99
+ ds = _trim_trailing_padding(ds)
100
+ n = ds.n_points
101
+ if n <= target_points or ds.n_channels == 0:
102
+ return ds
103
+
104
+ finite_counts = np.count_nonzero(np.isfinite(ds.values), axis=0)
105
+ densest = int(np.argmax(finite_counts))
106
+ y = ds.values[:, densest]
107
+
108
+ bucket_count = max(1, target_points // 2)
109
+ edges = np.linspace(0, n, bucket_count + 1)
110
+ keep: set[int] = set()
111
+ for b in range(bucket_count):
112
+ start, end = int(edges[b]), int(edges[b + 1])
113
+ if start >= end:
114
+ continue
115
+ seg = y[start:end]
116
+ finite = np.isfinite(seg)
117
+ if not finite.any():
118
+ keep.add(start) # nothing finite in this span -> keep a placeholder row
119
+ continue
120
+ local = np.flatnonzero(finite)
121
+ seg_finite = seg[local]
122
+ keep.add(start + int(local[int(np.argmin(seg_finite))]))
123
+ keep.add(start + int(local[int(np.argmax(seg_finite))]))
124
+
125
+ idx = np.fromiter(sorted(keep), dtype=np.int64)
126
+ return DataStruct(
127
+ time=ds.time[idx],
128
+ values=ds.values[idx, :],
129
+ labels=ds.labels,
130
+ units=ds.units,
131
+ metadata=ds.metadata,
132
+ )