@aryacode/dsh-engineering 0.4.7 → 0.4.20

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 (160) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +34 -26
  3. package/cordis.patch.yml +7 -6
  4. package/entries/config.js +62 -0
  5. package/entries/ftview.js +5 -4
  6. package/entries/l5x.js +47 -15
  7. package/entries/mwi.js +26 -6
  8. package/entries/optix.js +105 -0
  9. package/lib/client.js +335 -6
  10. package/package.json +11 -10
  11. package/vendor/l5x/studio-bridge/NuGet.Config +1 -1
  12. package/vendor/mwi/README.md +39 -18
  13. package/vendor/mwi/SKILL.md +26 -20
  14. package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -2763
  15. package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -30
  16. package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -22
  17. package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -339
  18. package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -765
  19. package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -399
  20. package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +313 -313
  21. package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -520
  22. package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -133
  23. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -672
  24. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -86
  25. package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -208
  26. package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +128 -128
  27. package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +673 -673
  28. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -790
  29. package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +642 -642
  30. package/vendor/mwi/lib/core/code-engine.d.ts +13 -0
  31. package/vendor/mwi/lib/core/code-engine.js +18 -1
  32. package/vendor/mwi/lib/edit.d.ts +1 -1
  33. package/vendor/mwi/lib/edit.js +1 -1
  34. package/vendor/mwi/lib/read/service.js +8 -1
  35. package/vendor/mwi/lib/session/backup.d.ts +55 -0
  36. package/vendor/mwi/lib/session/backup.js +232 -0
  37. package/vendor/mwi/lib/session/project.d.ts +4 -1
  38. package/vendor/mwi/lib/session/project.js +13 -2
  39. package/vendor/mwi/lib/session/service.d.ts +13 -6
  40. package/vendor/mwi/lib/session/service.js +127 -36
  41. package/vendor/mwi/lib/session/workspace.d.ts +10 -0
  42. package/vendor/mwi/lib/session/workspace.js +53 -3
  43. package/vendor/mwi/lib/skill.d.ts +2 -2
  44. package/vendor/mwi/lib/skill.js +2 -2
  45. package/vendor/mwi/lib/tools/edit.d.ts +3 -3
  46. package/vendor/mwi/lib/tools/edit.js +14 -15
  47. package/vendor/mwi/lib/tools/read.js +1 -1
  48. package/vendor/mwi/lib/tools/session.js +13 -9
  49. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix +81 -81
  50. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix.design +38 -38
  51. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Alarms/Alarms.yaml +3 -3
  52. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/AryaNewHMIProject_IPC.yaml +67 -67
  53. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
  54. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Converters/Converters.yaml +2 -2
  55. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/DataStores/DataStores.yaml +2 -2
  56. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Loggers/Loggers.yaml +2 -2
  57. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/MQTT/MQTT.yaml +2 -2
  58. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Model/Model.yaml +2 -2
  59. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/NetLogic/NetLogic.yaml +26 -26
  60. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
  61. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Recipes/Recipes.yaml +2 -2
  62. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Reports/Reports.yaml +2 -2
  63. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Retentivity/Retentivity.yaml +57 -57
  64. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Groups/Groups.yaml +3 -3
  65. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Roles/Roles.yaml +3 -3
  66. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Security.yaml +7 -7
  67. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Users/Users.yaml +3 -3
  68. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/System/System.yaml +2 -2
  69. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Translations/Translations.yaml +16 -16
  70. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
  71. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Screens/Screens.yaml +2 -2
  72. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/UI.yaml +250 -250
  73. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
  74. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.code-workspace +9 -9
  75. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.csproj +19 -19
  76. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.references +172 -172
  77. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.sln +21 -21
  78. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +20 -20
  79. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +11 -11
  80. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
  81. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/UserDefinedModule.xml +26 -26
  82. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix +97 -97
  83. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix.design +38 -38
  84. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Alarms/Alarms.yaml +3 -3
  85. package/vendor/optix/templates/arya-optix-panel/project/Nodes/AryaNewHMIProject_OptixPanel.yaml +67 -67
  86. package/vendor/optix/templates/arya-optix-panel/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
  87. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Converters/Converters.yaml +2 -2
  88. package/vendor/optix/templates/arya-optix-panel/project/Nodes/DataStores/DataStores.yaml +2 -2
  89. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Loggers/Loggers.yaml +2 -2
  90. package/vendor/optix/templates/arya-optix-panel/project/Nodes/MQTT/MQTT.yaml +2 -2
  91. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Model/Model.yaml +2 -2
  92. package/vendor/optix/templates/arya-optix-panel/project/Nodes/NetLogic/NetLogic.yaml +26 -26
  93. package/vendor/optix/templates/arya-optix-panel/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
  94. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Recipes/Recipes.yaml +2 -2
  95. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Reports/Reports.yaml +2 -2
  96. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Retentivity/Retentivity.yaml +57 -57
  97. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Groups/Groups.yaml +3 -3
  98. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Roles/Roles.yaml +3 -3
  99. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Security.yaml +7 -7
  100. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Users/Users.yaml +3 -3
  101. package/vendor/optix/templates/arya-optix-panel/project/Nodes/System/System.yaml +444 -444
  102. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Translations/Translations.yaml +81 -81
  103. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
  104. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Screens/Screens.yaml +2 -2
  105. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidget.yaml +163 -163
  106. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidgetComponents/DeviceSettingsWidgetComponents.yaml +1663 -1663
  107. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FTRemoteAccessWidgetComponents/FTRemoteAccessWidgetComponents.yaml +2471 -2471
  108. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FactoryTalkRemoteAccessWidget.yaml +218 -218
  109. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidget.yaml +524 -524
  110. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidgetComponents/NetworkSettingsWidgetComponents.yaml +690 -690
  111. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/SettingsWidgets.yaml +1265 -1265
  112. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/UI.yaml +252 -252
  113. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
  114. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ApplicationNameLogic.cs +19 -19
  115. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.code-workspace +9 -9
  116. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.csproj +19 -19
  117. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.references +172 -172
  118. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.sln +21 -21
  119. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ConnectionSwitchLogic.cs +74 -74
  120. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DateAndTimeWidgetLogic.cs +420 -420
  121. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DeviceSettingsWidgetLogic.cs +34 -34
  122. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTOptixStudioVersionLogic.cs +47 -47
  123. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTRemoteAccessWidgetSetupLogic.cs +153 -153
  124. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionApprovalLogic.cs +118 -118
  125. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionReceiverLogic.cs +166 -166
  126. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkConfiguratorEditModelsLogic.cs +56 -56
  127. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfaceLogic.cs +138 -138
  128. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelReaderLogic.cs +87 -87
  129. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelToSystemWriterLogic.cs +139 -139
  130. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkSettingsLogic.cs +168 -168
  131. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/FTRemoteAccessWidgetDataObject.cs +106 -106
  132. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +37 -37
  133. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +91 -91
  134. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/RequestAssistanceSwitchLogic.cs +93 -93
  135. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
  136. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/SystemToNetworkInterfacesEditModelWriterLogic.cs +77 -77
  137. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/UserDefinedModule.xml +111 -111
  138. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/WarningIcon.svg +4 -4
  139. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
  140. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
  141. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
  142. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
  143. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
  144. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
  145. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
  146. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
  147. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
  148. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
  149. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
  150. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
  151. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
  152. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
  153. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
  154. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
  155. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
  156. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
  157. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
  158. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
  159. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
  160. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
@@ -1,520 +1,520 @@
1
- """Search the MotionWorks manuals that ship with the IDE.
2
-
3
- The goal for this plugin says to learn about MotionWorks programming, and it turns out the IDE
4
- installs its own documentation - three PDF manuals plus 259 .chm help files - so an agent writing
5
- MotionWorks code can be given the vendor's own words instead of guessing at an API.
6
-
7
- WHAT IS READABLE AND WHAT IS NOT.
8
-
9
- The PDFs are ordinary PDFs and are read here with zlib alone: inflate every stream, keep the ones
10
- containing text operators, and pull the drawn strings out. Measured on the Toolbox Manual,
11
- 217 KB of searchable text from 285 streams.
12
-
13
- Part of that text is drawn with a font whose encoding is the ASCII codepoint shifted by 29, in
14
- EITHER direction:
15
-
16
- '5HSODFHB0H' +29 -> 'Replace_Me'
17
- 'ptboolo|pqor' -29 -> 'SWERROR_STRUCT'
18
- '7KLV' +29 -> 'This'
19
- 'Introduction' neither - already plain
20
-
21
- so a token is decoded only when it carries a character that does not occur inside English words -
22
- a digit, '|', a backtick - and then both directions are tried and the one that scores as more
23
- English wins. Applying one direction to everything makes plain words worse, which is how a first
24
- attempt turned "Introduction" into "fntroduction".
25
-
26
- The .chm files are NOT read. They are ITSF containers with LZX-compressed content; the file list
27
- is visible and the text is not, and hh.exe -decompile produced nothing here. Their topics are
28
- listed in the manifest, so a caller can at least be told that a topic exists and which help file
29
- covers it.
30
-
31
- The extracted text is cached beside the plugin, because inflating a 6.7 MB manual takes a moment
32
- and the manuals do not change.
33
- """
34
- from __future__ import annotations
35
-
36
- import re
37
- import zlib
38
- from dataclasses import dataclass
39
- from pathlib import Path
40
-
41
- from .errors import NotFound
42
-
43
- #: Where the IDE installs its documentation. The manuals directory is the readable part.
44
- IDE_ROOTS = (
45
- Path(r"C:\Program Files (x86)\Yaskawa\MotionWorks IEC 3 Pro"),
46
- Path(r"C:\Program Files\Yaskawa\MotionWorks IEC 3 Pro"),
47
- )
48
- MANUAL_DIR = "Manuals"
49
- HELP_DIR = "Help"
50
-
51
- STREAM = re.compile(rb"stream\r?\n(.*?)endstream", re.DOTALL)
52
- LITERAL = re.compile(rb"\((?:\\.|[^\\()])*\)")
53
- HEXSTR = re.compile(rb"<([0-9A-Fa-f\s]+)>")
54
-
55
- _SHIFT = 0x1D
56
- _FINGERPRINT = set("0123456789|`~\\^}{[]#@$%&*+=<>/")
57
- _VOWELS = set("aeiouAEIOU")
58
-
59
-
60
- @dataclass
61
- class Manual:
62
- """One document, and whether its text can be read."""
63
-
64
- name: str
65
- path: Path
66
- bytes: int
67
- readable: bool
68
- note: str = ""
69
-
70
-
71
- def manuals_root() -> Path:
72
- for root in IDE_ROOTS:
73
- candidate = root / MANUAL_DIR
74
- if candidate.is_dir():
75
- return candidate
76
- raise NotFound(
77
- "no MotionWorks Manuals directory found; looked in "
78
- + " and ".join(str(r / MANUAL_DIR) for r in IDE_ROOTS)
79
- )
80
-
81
-
82
- def list_manuals() -> list[Manual]:
83
- root = manuals_root()
84
- found = []
85
- for path in sorted(root.glob("*.pdf")):
86
- found.append(Manual(name=path.stem, path=path, bytes=path.stat().st_size, readable=True))
87
- for path in sorted(root.glob("*.chm")):
88
- found.append(Manual(
89
- name=path.stem, path=path, bytes=path.stat().st_size, readable=False,
90
- note="compiled help (ITSF/LZX); the topic list is available, the text is not",
91
- ))
92
- return found
93
-
94
-
95
- def help_topics() -> list[str]:
96
- """The .chm help files, which name their subject in the filename."""
97
- for root in IDE_ROOTS:
98
- help_dir = root / HELP_DIR
99
- if help_dir.is_dir():
100
- names = {p.stem for p in help_dir.glob("*.chm")}
101
- # Drop the locale-suffixed duplicates (edit033 vs edit001) and locale folders.
102
- english = sorted(
103
- n for n in names
104
- if re.fullmatch(r"[A-Za-z_]+0*1?", n) or not re.search(r"\d{3}$", n)
105
- )
106
- return english
107
- raise NotFound("no MotionWorks Help directory found")
108
-
109
-
110
- # ── PDF text ─────────────────────────────────────────────────────────────────────
111
-
112
- def _unescape(raw: bytes) -> str:
113
- out = bytearray()
114
- i = 0
115
- while i < len(raw):
116
- c = raw[i]
117
- if c == 0x5C and i + 1 < len(raw):
118
- n = raw[i + 1]
119
- simple = {0x6E: 10, 0x72: 13, 0x74: 9, 0x62: 8, 0x66: 12}
120
- if n in simple:
121
- out.append(simple[n]); i += 2; continue
122
- if 0x30 <= n <= 0x37:
123
- j, digits = i + 1, b""
124
- while j < len(raw) and len(digits) < 3 and 0x30 <= raw[j] <= 0x37:
125
- digits += bytes([raw[j]]); j += 1
126
- out.append(int(digits, 8) & 0xFF); i = j; continue
127
- out.append(n); i += 2; continue
128
- out.append(c); i += 1
129
- return out.decode("latin1")
130
-
131
-
132
- def _score(text: str) -> float:
133
- if not text or "\ufffd" in text:
134
- return -1.0
135
- letters = sum(1 for c in text if c.isalpha())
136
- if letters == 0:
137
- return -1.0
138
- digits = sum(1 for c in text if c.isdigit())
139
- symbols = sum(1 for c in text if not c.isalnum() and c not in " _")
140
- vowels = sum(1 for c in text if c in _VOWELS) / letters
141
- return vowels * 2.0 - digits * 0.5 - symbols * 0.3
142
-
143
-
144
- def _decode_token(token: str) -> str:
145
- if not any(c in _FINGERPRINT for c in token):
146
- return token
147
-
148
- def shift(delta: int) -> str:
149
- out = []
150
- for c in token:
151
- n = ord(c) + delta
152
- out.append(chr(n) if 0x20 <= n <= 0x7E else "\ufffd")
153
- return "".join(out)
154
-
155
- return max((token, shift(_SHIFT), shift(-_SHIFT)), key=_score)
156
-
157
-
158
- # ── PDF text, by the document's own character maps ──────────────────────────────
159
- #
160
- # The heuristic below guesses at a font encoding. This does not: a PDF that uses a non-ASCII font
161
- # ships a /ToUnicode CMap saying what each byte means, and reading that is exact. The guess stays as
162
- # a fallback for fonts that carry no map.
163
-
164
- _OBJ = re.compile(rb"(?<![0-9])(\d+)\s+(\d+)\s+obj\b")
165
- _STREAM = re.compile(rb"stream\r?\n", re.DOTALL)
166
- _FONT_REF = re.compile(rb"/([A-Za-z0-9]+)\s+(\d+)\s+0\s+R")
167
-
168
-
169
- def _inflate(data: bytes) -> bytes | None:
170
- """FlateDecode, tolerating the variants that appear in real files."""
171
- for attempt in (data, data[1:], data[2:]):
172
- try:
173
- return zlib.decompress(attempt)
174
- except zlib.error:
175
- continue
176
- try:
177
- return zlib.decompressobj(-15).decompress(data)
178
- except zlib.error:
179
- return None
180
-
181
-
182
- def _objects(data: bytes) -> dict[int, bytes]:
183
- """Every `N G obj ... endobj` body, keyed by object number."""
184
- found: dict[int, bytes] = {}
185
- for m in _OBJ.finditer(data):
186
- end = data.find(b"endobj", m.end())
187
- found[int(m.group(1))] = data[m.end():end if end >= 0 else len(data)]
188
- return found
189
-
190
-
191
- def _object_stream(body: bytes) -> bytes | None:
192
- m = _STREAM.search(body)
193
- if not m:
194
- return None
195
- raw = body[m.end():body.find(b"endstream", m.end())]
196
- if b"FlateDecode" in body[:m.start()]:
197
- return _inflate(raw)
198
- return raw
199
-
200
-
201
- def _parse_cmap(text: bytes) -> dict[int, str]:
202
- """code -> character, from the bfchar and bfrange sections of a ToUnicode CMap."""
203
- mapping: dict[int, str] = {}
204
- for block in re.findall(rb"beginbfchar(.*?)endbfchar", text, re.DOTALL):
205
- for src, dst in re.findall(rb"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", block):
206
- try:
207
- mapping[int(src, 16)] = bytes.fromhex(dst.decode()).decode("utf-16-be", "replace")
208
- except Exception:
209
- continue
210
- for block in re.findall(rb"beginbfrange(.*?)endbfrange", text, re.DOTALL):
211
- for lo, hi, dst in re.findall(
212
- rb"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", block):
213
- try:
214
- start, stop, base = int(lo, 16), int(hi, 16), int(dst, 16)
215
- except Exception:
216
- continue
217
- if stop - start > 65535:
218
- continue
219
- for offset in range(stop - start + 1):
220
- mapping[start + offset] = chr(base + offset)
221
- return mapping
222
-
223
-
224
- def _font_maps(objects: dict[int, bytes]) -> tuple[dict[str, dict[int, str]], dict[str, bool]]:
225
- """Resource name -> its CMap, and resource name -> whether the font is two-byte.
226
-
227
- The name is assigned by whoever REFERENCES the font, not by the font itself, so the lookup runs
228
- in that direction: find the font object, then find every object naming it.
229
- """
230
- cmaps: dict[int, dict[int, str]] = {}
231
- for number, body in objects.items():
232
- if b"beginbfchar" in body or b"beginbfrange" in body:
233
- stream = _object_stream(body)
234
- if stream:
235
- cmaps[number] = _parse_cmap(stream)
236
- if not cmaps:
237
- return {}, {}
238
-
239
- # font object number -> the CMap it points at
240
- font_to_cmap: dict[int, dict[int, str]] = {}
241
- two_byte_objects: set[int] = set()
242
- for number, body in objects.items():
243
- ref = re.search(rb"/ToUnicode\s+(\d+)\s+0\s+R", body)
244
- if ref and int(ref.group(1)) in cmaps:
245
- font_to_cmap[number] = cmaps[int(ref.group(1))]
246
- if b"/Type0" in body:
247
- two_byte_objects.add(number)
248
-
249
- # every `/Name N 0 R` anywhere, where N is a font object
250
- by_name: dict[str, dict[int, str]] = {}
251
- two_byte: dict[str, bool] = {}
252
- for body in objects.values():
253
- for name, target in _FONT_REF.findall(body):
254
- target = int(target)
255
- if target in font_to_cmap:
256
- key = name.decode()
257
- by_name[key] = font_to_cmap[target]
258
- if target in two_byte_objects:
259
- two_byte[key] = True
260
- return by_name, two_byte
261
-
262
-
263
- def _apply_cmap(raw: bytes, cmap: dict[int, str] | None, two_byte: bool) -> str:
264
- if not cmap:
265
- return raw.decode("latin1", "replace")
266
- if two_byte:
267
- return "".join(cmap.get((raw[i] << 8) | raw[i + 1], "")
268
- for i in range(0, len(raw) - 1, 2))
269
- return "".join(cmap.get(c, chr(c)) for c in raw)
270
-
271
-
272
- _TEXT_OP = re.compile(
273
- rb"/([A-Za-z0-9]+)\s+[\d.]+\s+Tf" # 1 font selection
274
- rb"|\[(.*?)\]\s*TJ" # 2 array show
275
- rb"|\((.*?)(?<!\\)\)\s*Tj" # 3 string show
276
- rb"|(T\*|Td|TD)", # 4 positioning
277
- re.DOTALL,
278
- )
279
- _IN_ARRAY = re.compile(rb"\((.*?)(?<!\\)\)", re.DOTALL)
280
-
281
-
282
- def extract_pdf_by_cmap(path: Path) -> str | None:
283
- """Text read through the document's own ToUnicode maps, or None if it has none."""
284
- data = path.read_bytes()
285
- objects = _objects(data)
286
- by_name, two_byte = _font_maps(objects)
287
- if not by_name:
288
- return None
289
-
290
- pieces: list[str] = []
291
- for body in objects.values():
292
- if b"Tj" not in body and b"TJ" not in body:
293
- continue
294
- stream = _object_stream(body)
295
- if not stream or (b"Tj" not in stream and b"TJ" not in stream):
296
- continue
297
- cmap, wide = None, False
298
- for m in _TEXT_OP.finditer(stream):
299
- if m.group(1):
300
- name = m.group(1).decode()
301
- cmap, wide = by_name.get(name), two_byte.get(name, False)
302
- elif m.group(2) is not None:
303
- for s in _IN_ARRAY.finditer(m.group(2)):
304
- pieces.append(_apply_cmap(_unescape_bytes(s.group(1)), cmap, wide))
305
- elif m.group(3) is not None:
306
- pieces.append(_apply_cmap(_unescape_bytes(m.group(3)), cmap, wide))
307
- elif m.group(4):
308
- pieces.append("\n")
309
-
310
- if not pieces:
311
- return None
312
- text = "".join(pieces)
313
- text = re.sub(r"[ \t]+", " ", text)
314
- return re.sub(r"\n{3,}", "\n\n", text)
315
-
316
-
317
- def _unescape_bytes(raw: bytes) -> bytes:
318
- """PDF string escapes, as bytes, so a multi-byte font encoding survives intact."""
319
- out, i = bytearray(), 0
320
- simple = {0x6E: 10, 0x72: 13, 0x74: 9, 0x62: 8, 0x66: 12,
321
- 0x28: 0x28, 0x29: 0x29, 0x5C: 0x5C}
322
- while i < len(raw):
323
- if raw[i] == 0x5C and i + 1 < len(raw):
324
- nxt = raw[i + 1]
325
- if nxt in simple:
326
- out.append(simple[nxt]); i += 2; continue
327
- if 0x30 <= nxt <= 0x37:
328
- j, digits = i + 1, b""
329
- while j < len(raw) and len(digits) < 3 and 0x30 <= raw[j] <= 0x37:
330
- digits += bytes([raw[j]]); j += 1
331
- out.append(int(digits, 8) & 0xFF); i = j; continue
332
- i += 2; continue
333
- out.append(raw[i]); i += 1
334
- return bytes(out)
335
-
336
-
337
- def _extract_pdf_by_shift(path: Path) -> str:
338
- data = path.read_bytes()
339
- chunks: list[str] = []
340
- for raw in STREAM.findall(data):
341
- body = raw
342
- if body[:2] in (b"\x78\x9c", b"\x78\x01", b"\x78\xda"):
343
- try:
344
- body = zlib.decompress(body)
345
- except Exception:
346
- continue
347
- if b"Tj" not in body and b"TJ" not in body:
348
- continue
349
- for line in body.split(b"\n"):
350
- if b"Tj" not in line and b"TJ" not in line:
351
- continue
352
- piece = [_unescape(m.group(0)[1:-1]) for m in LITERAL.finditer(line)]
353
- for m in HEXSTR.finditer(line):
354
- try:
355
- piece.append(bytes.fromhex(m.group(1).decode("ascii")).decode("utf-16-be", "replace"))
356
- except Exception:
357
- pass
358
- if piece:
359
- chunks.append("".join(piece))
360
- text = " ".join(chunks)
361
- text = re.sub(r"[\x00-\x1f]+", " ", text)
362
- text = re.sub(r"\s{2,}", " ", text)
363
- return re.sub(r"[!-~]{4,}", lambda m: _decode_token(m.group(0)), text)
364
-
365
-
366
- def manual_text(name: str, cache_dir: Path | None = None) -> str:
367
- """The decoded text of one manual, cached on disk."""
368
- root = manuals_root()
369
- matches = [p for p in root.glob("*.pdf") if name.lower() in p.stem.lower()]
370
- if not matches:
371
- available = ", ".join(p.stem for p in root.glob("*.pdf"))
372
- raise NotFound(f"no manual matching {name!r}. Available: {available}")
373
- path = matches[0]
374
-
375
- from .knowledge import cache_root
376
- import hashlib
377
- root_cache = cache_root()
378
- cache_base = (cache_dir or root_cache / 'legacy').resolve()
379
- if not cache_base.is_relative_to(root_cache):
380
- raise ValueError('Manual extraction cache must stay inside workspace reference storage')
381
- key = hashlib.sha256(path.read_bytes()).hexdigest()
382
- cache = cache_base / (key + '.txt')
383
- if not cache.resolve().is_relative_to(root_cache):
384
- raise ValueError('Linked manual extraction cache escapes reference storage')
385
- try:
386
- if cache.is_file() and cache.stat().st_mtime >= path.stat().st_mtime:
387
- return cache.read_text(encoding="utf-8")
388
- except OSError:
389
- pass
390
-
391
- text = extract_pdf(path)
392
- try:
393
- cache.parent.mkdir(parents=True, exist_ok=True)
394
- cache.write_text(text, encoding="utf-8")
395
- except OSError:
396
- pass
397
- return text
398
-
399
-
400
- def search(term: str, name: str | None = None, limit: int = 5,
401
- cache_dir: Path | None = None) -> list[dict]:
402
- """Find a term across the manuals, with surrounding context.
403
-
404
- A term's words are allowed to sit apart in the text: the PDFs draw each word as its own
405
- operation, so a phrase has to be matched with whitespace between its parts or it never hits.
406
- """
407
- targets = list_manuals()
408
- if name:
409
- targets = [m for m in targets if name.lower() in m.name.lower() and m.readable]
410
- else:
411
- targets = [m for m in targets if m.readable]
412
-
413
- pattern = re.compile(r"\W+".join(re.escape(w) for w in term.split()), re.IGNORECASE)
414
- out: list[dict] = []
415
- for manual in targets:
416
- try:
417
- text = manual_text(manual.name, cache_dir=cache_dir)
418
- except Exception:
419
- continue
420
- hits = list(pattern.finditer(text))
421
- if not hits:
422
- continue
423
- out.append({
424
- "manual": manual.name,
425
- "hits": len(hits),
426
- "passages": [
427
- text[max(0, h.start() - 200):h.end() + 400].strip()
428
- for h in hits[:limit]
429
- ],
430
- })
431
- return out
432
-
433
-
434
- def extract_pdf(path: Path) -> str:
435
- """Text from a PDF, preferring the document's own character maps.
436
-
437
- The CMap path is exact and is tried first. The older heuristic - inflate, pull the drawn
438
- strings, and guess at a +/-29 font shift - remains as a fallback, because some PDFs really do
439
- use a shifted encoding and ship no map to read.
440
- """
441
- candidates: list[tuple[str, str]] = []
442
-
443
- try:
444
- by_cmap = extract_pdf_by_cmap(path)
445
- if by_cmap and by_cmap.strip():
446
- candidates.append(("cmap", by_cmap))
447
- except Exception:
448
- pass
449
-
450
- try:
451
- plain = _extract_pdf_plain(path)
452
- if plain.strip():
453
- candidates.append(("plain", plain))
454
- except Exception:
455
- pass
456
-
457
- try:
458
- shifted = _extract_pdf_by_shift(path)
459
- if shifted.strip():
460
- candidates.append(("shift", shifted))
461
- except Exception:
462
- pass
463
-
464
- if not candidates:
465
- return ""
466
-
467
- # Judge by English: count the ordinary words in a sample. The three decoders differ enormously
468
- # when the right one is chosen and barely at all when it is not, so this only has to be roughly
469
- # right - but it does have to be about words, which the vowel-based _score is not.
470
- return max(candidates, key=lambda pair: _english(pair[1]))[1]
471
-
472
-
473
- #: Ordinary words, used only to decide which decoder produced readable text.
474
- _ENGLISH = frozenset("""
475
- the and for with this that are not from which function block variable type value input output
476
- is of to in on be by or an as it at we you they can may must will would should has have been
477
- when where what how all any each other more most such only same than then there their them
478
- """.split())
479
-
480
-
481
- def _english(text: str) -> float:
482
- """Fraction of the words in a sample that are ordinary English words."""
483
- words = re.findall(r"[A-Za-z]{3,}", text[:20000])
484
- if not words:
485
- return 0.0
486
- return sum(1 for w in words if w.lower() in _ENGLISH) / len(words)
487
-
488
-
489
- def _extract_pdf_plain(path: Path) -> str:
490
- """The drawn strings decoded as latin-1, with no re-encoding guess applied.
491
-
492
- This is what the shift heuristic is a correction FOR. Some documents are already plain and the
493
- correction damages them - AN.MPIEC.01 reads at 24% this way and 0% with the shift - so the two
494
- are offered as alternatives and the scorer picks.
495
- """
496
- data = path.read_bytes()
497
- chunks: list[str] = []
498
- for raw in STREAM.findall(data):
499
- body = raw
500
- if body[:2] in (b"\x78\x9c", b"\x78\x01", b"\x78\xda"):
501
- try:
502
- body = zlib.decompress(body)
503
- except Exception:
504
- continue
505
- if b"Tj" not in body and b"TJ" not in body:
506
- continue
507
- # Walk the whole stream, not line by line: an operator and its operand are often on
508
- # different lines, and a drawn string can contain a newline of its own.
509
- for m in _TEXT_RUN.finditer(body):
510
- piece = m.group(1) if m.group(1) is not None else m.group(2)
511
- for s in _STRING_IN.finditer(piece):
512
- chunks.append(_unescape(s.group(1)))
513
- chunks.append("\n")
514
- text = "".join(chunks)
515
- text = re.sub(r"[ \t]{2,}", " ", text)
516
- return re.sub(r"\n{3,}", "\n\n", text)
517
-
518
- #: A drawn-string run: either the operand of Tj, or the array operand of TJ.
519
- _TEXT_RUN = re.compile(rb"\((.*?)(?<!\\)\)\s*Tj|\[(.*?)\]\s*TJ", re.DOTALL)
520
- _STRING_IN = re.compile(rb"\((.*?)(?<!\\)\)", re.DOTALL)
1
+ """Search the MotionWorks manuals that ship with the IDE.
2
+
3
+ The goal for this plugin says to learn about MotionWorks programming, and it turns out the IDE
4
+ installs its own documentation - three PDF manuals plus 259 .chm help files - so an agent writing
5
+ MotionWorks code can be given the vendor's own words instead of guessing at an API.
6
+
7
+ WHAT IS READABLE AND WHAT IS NOT.
8
+
9
+ The PDFs are ordinary PDFs and are read here with zlib alone: inflate every stream, keep the ones
10
+ containing text operators, and pull the drawn strings out. Measured on the Toolbox Manual,
11
+ 217 KB of searchable text from 285 streams.
12
+
13
+ Part of that text is drawn with a font whose encoding is the ASCII codepoint shifted by 29, in
14
+ EITHER direction:
15
+
16
+ '5HSODFHB0H' +29 -> 'Replace_Me'
17
+ 'ptboolo|pqor' -29 -> 'SWERROR_STRUCT'
18
+ '7KLV' +29 -> 'This'
19
+ 'Introduction' neither - already plain
20
+
21
+ so a token is decoded only when it carries a character that does not occur inside English words -
22
+ a digit, '|', a backtick - and then both directions are tried and the one that scores as more
23
+ English wins. Applying one direction to everything makes plain words worse, which is how a first
24
+ attempt turned "Introduction" into "fntroduction".
25
+
26
+ The .chm files are NOT read. They are ITSF containers with LZX-compressed content; the file list
27
+ is visible and the text is not, and hh.exe -decompile produced nothing here. Their topics are
28
+ listed in the manifest, so a caller can at least be told that a topic exists and which help file
29
+ covers it.
30
+
31
+ The extracted text is cached beside the plugin, because inflating a 6.7 MB manual takes a moment
32
+ and the manuals do not change.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ import zlib
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+
41
+ from .errors import NotFound
42
+
43
+ #: Where the IDE installs its documentation. The manuals directory is the readable part.
44
+ IDE_ROOTS = (
45
+ Path(r"C:\Program Files (x86)\Yaskawa\MotionWorks IEC 3 Pro"),
46
+ Path(r"C:\Program Files\Yaskawa\MotionWorks IEC 3 Pro"),
47
+ )
48
+ MANUAL_DIR = "Manuals"
49
+ HELP_DIR = "Help"
50
+
51
+ STREAM = re.compile(rb"stream\r?\n(.*?)endstream", re.DOTALL)
52
+ LITERAL = re.compile(rb"\((?:\\.|[^\\()])*\)")
53
+ HEXSTR = re.compile(rb"<([0-9A-Fa-f\s]+)>")
54
+
55
+ _SHIFT = 0x1D
56
+ _FINGERPRINT = set("0123456789|`~\\^}{[]#@$%&*+=<>/")
57
+ _VOWELS = set("aeiouAEIOU")
58
+
59
+
60
+ @dataclass
61
+ class Manual:
62
+ """One document, and whether its text can be read."""
63
+
64
+ name: str
65
+ path: Path
66
+ bytes: int
67
+ readable: bool
68
+ note: str = ""
69
+
70
+
71
+ def manuals_root() -> Path:
72
+ for root in IDE_ROOTS:
73
+ candidate = root / MANUAL_DIR
74
+ if candidate.is_dir():
75
+ return candidate
76
+ raise NotFound(
77
+ "no MotionWorks Manuals directory found; looked in "
78
+ + " and ".join(str(r / MANUAL_DIR) for r in IDE_ROOTS)
79
+ )
80
+
81
+
82
+ def list_manuals() -> list[Manual]:
83
+ root = manuals_root()
84
+ found = []
85
+ for path in sorted(root.glob("*.pdf")):
86
+ found.append(Manual(name=path.stem, path=path, bytes=path.stat().st_size, readable=True))
87
+ for path in sorted(root.glob("*.chm")):
88
+ found.append(Manual(
89
+ name=path.stem, path=path, bytes=path.stat().st_size, readable=False,
90
+ note="compiled help (ITSF/LZX); the topic list is available, the text is not",
91
+ ))
92
+ return found
93
+
94
+
95
+ def help_topics() -> list[str]:
96
+ """The .chm help files, which name their subject in the filename."""
97
+ for root in IDE_ROOTS:
98
+ help_dir = root / HELP_DIR
99
+ if help_dir.is_dir():
100
+ names = {p.stem for p in help_dir.glob("*.chm")}
101
+ # Drop the locale-suffixed duplicates (edit033 vs edit001) and locale folders.
102
+ english = sorted(
103
+ n for n in names
104
+ if re.fullmatch(r"[A-Za-z_]+0*1?", n) or not re.search(r"\d{3}$", n)
105
+ )
106
+ return english
107
+ raise NotFound("no MotionWorks Help directory found")
108
+
109
+
110
+ # ── PDF text ─────────────────────────────────────────────────────────────────────
111
+
112
+ def _unescape(raw: bytes) -> str:
113
+ out = bytearray()
114
+ i = 0
115
+ while i < len(raw):
116
+ c = raw[i]
117
+ if c == 0x5C and i + 1 < len(raw):
118
+ n = raw[i + 1]
119
+ simple = {0x6E: 10, 0x72: 13, 0x74: 9, 0x62: 8, 0x66: 12}
120
+ if n in simple:
121
+ out.append(simple[n]); i += 2; continue
122
+ if 0x30 <= n <= 0x37:
123
+ j, digits = i + 1, b""
124
+ while j < len(raw) and len(digits) < 3 and 0x30 <= raw[j] <= 0x37:
125
+ digits += bytes([raw[j]]); j += 1
126
+ out.append(int(digits, 8) & 0xFF); i = j; continue
127
+ out.append(n); i += 2; continue
128
+ out.append(c); i += 1
129
+ return out.decode("latin1")
130
+
131
+
132
+ def _score(text: str) -> float:
133
+ if not text or "\ufffd" in text:
134
+ return -1.0
135
+ letters = sum(1 for c in text if c.isalpha())
136
+ if letters == 0:
137
+ return -1.0
138
+ digits = sum(1 for c in text if c.isdigit())
139
+ symbols = sum(1 for c in text if not c.isalnum() and c not in " _")
140
+ vowels = sum(1 for c in text if c in _VOWELS) / letters
141
+ return vowels * 2.0 - digits * 0.5 - symbols * 0.3
142
+
143
+
144
+ def _decode_token(token: str) -> str:
145
+ if not any(c in _FINGERPRINT for c in token):
146
+ return token
147
+
148
+ def shift(delta: int) -> str:
149
+ out = []
150
+ for c in token:
151
+ n = ord(c) + delta
152
+ out.append(chr(n) if 0x20 <= n <= 0x7E else "\ufffd")
153
+ return "".join(out)
154
+
155
+ return max((token, shift(_SHIFT), shift(-_SHIFT)), key=_score)
156
+
157
+
158
+ # ── PDF text, by the document's own character maps ──────────────────────────────
159
+ #
160
+ # The heuristic below guesses at a font encoding. This does not: a PDF that uses a non-ASCII font
161
+ # ships a /ToUnicode CMap saying what each byte means, and reading that is exact. The guess stays as
162
+ # a fallback for fonts that carry no map.
163
+
164
+ _OBJ = re.compile(rb"(?<![0-9])(\d+)\s+(\d+)\s+obj\b")
165
+ _STREAM = re.compile(rb"stream\r?\n", re.DOTALL)
166
+ _FONT_REF = re.compile(rb"/([A-Za-z0-9]+)\s+(\d+)\s+0\s+R")
167
+
168
+
169
+ def _inflate(data: bytes) -> bytes | None:
170
+ """FlateDecode, tolerating the variants that appear in real files."""
171
+ for attempt in (data, data[1:], data[2:]):
172
+ try:
173
+ return zlib.decompress(attempt)
174
+ except zlib.error:
175
+ continue
176
+ try:
177
+ return zlib.decompressobj(-15).decompress(data)
178
+ except zlib.error:
179
+ return None
180
+
181
+
182
+ def _objects(data: bytes) -> dict[int, bytes]:
183
+ """Every `N G obj ... endobj` body, keyed by object number."""
184
+ found: dict[int, bytes] = {}
185
+ for m in _OBJ.finditer(data):
186
+ end = data.find(b"endobj", m.end())
187
+ found[int(m.group(1))] = data[m.end():end if end >= 0 else len(data)]
188
+ return found
189
+
190
+
191
+ def _object_stream(body: bytes) -> bytes | None:
192
+ m = _STREAM.search(body)
193
+ if not m:
194
+ return None
195
+ raw = body[m.end():body.find(b"endstream", m.end())]
196
+ if b"FlateDecode" in body[:m.start()]:
197
+ return _inflate(raw)
198
+ return raw
199
+
200
+
201
+ def _parse_cmap(text: bytes) -> dict[int, str]:
202
+ """code -> character, from the bfchar and bfrange sections of a ToUnicode CMap."""
203
+ mapping: dict[int, str] = {}
204
+ for block in re.findall(rb"beginbfchar(.*?)endbfchar", text, re.DOTALL):
205
+ for src, dst in re.findall(rb"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", block):
206
+ try:
207
+ mapping[int(src, 16)] = bytes.fromhex(dst.decode()).decode("utf-16-be", "replace")
208
+ except Exception:
209
+ continue
210
+ for block in re.findall(rb"beginbfrange(.*?)endbfrange", text, re.DOTALL):
211
+ for lo, hi, dst in re.findall(
212
+ rb"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", block):
213
+ try:
214
+ start, stop, base = int(lo, 16), int(hi, 16), int(dst, 16)
215
+ except Exception:
216
+ continue
217
+ if stop - start > 65535:
218
+ continue
219
+ for offset in range(stop - start + 1):
220
+ mapping[start + offset] = chr(base + offset)
221
+ return mapping
222
+
223
+
224
+ def _font_maps(objects: dict[int, bytes]) -> tuple[dict[str, dict[int, str]], dict[str, bool]]:
225
+ """Resource name -> its CMap, and resource name -> whether the font is two-byte.
226
+
227
+ The name is assigned by whoever REFERENCES the font, not by the font itself, so the lookup runs
228
+ in that direction: find the font object, then find every object naming it.
229
+ """
230
+ cmaps: dict[int, dict[int, str]] = {}
231
+ for number, body in objects.items():
232
+ if b"beginbfchar" in body or b"beginbfrange" in body:
233
+ stream = _object_stream(body)
234
+ if stream:
235
+ cmaps[number] = _parse_cmap(stream)
236
+ if not cmaps:
237
+ return {}, {}
238
+
239
+ # font object number -> the CMap it points at
240
+ font_to_cmap: dict[int, dict[int, str]] = {}
241
+ two_byte_objects: set[int] = set()
242
+ for number, body in objects.items():
243
+ ref = re.search(rb"/ToUnicode\s+(\d+)\s+0\s+R", body)
244
+ if ref and int(ref.group(1)) in cmaps:
245
+ font_to_cmap[number] = cmaps[int(ref.group(1))]
246
+ if b"/Type0" in body:
247
+ two_byte_objects.add(number)
248
+
249
+ # every `/Name N 0 R` anywhere, where N is a font object
250
+ by_name: dict[str, dict[int, str]] = {}
251
+ two_byte: dict[str, bool] = {}
252
+ for body in objects.values():
253
+ for name, target in _FONT_REF.findall(body):
254
+ target = int(target)
255
+ if target in font_to_cmap:
256
+ key = name.decode()
257
+ by_name[key] = font_to_cmap[target]
258
+ if target in two_byte_objects:
259
+ two_byte[key] = True
260
+ return by_name, two_byte
261
+
262
+
263
+ def _apply_cmap(raw: bytes, cmap: dict[int, str] | None, two_byte: bool) -> str:
264
+ if not cmap:
265
+ return raw.decode("latin1", "replace")
266
+ if two_byte:
267
+ return "".join(cmap.get((raw[i] << 8) | raw[i + 1], "")
268
+ for i in range(0, len(raw) - 1, 2))
269
+ return "".join(cmap.get(c, chr(c)) for c in raw)
270
+
271
+
272
+ _TEXT_OP = re.compile(
273
+ rb"/([A-Za-z0-9]+)\s+[\d.]+\s+Tf" # 1 font selection
274
+ rb"|\[(.*?)\]\s*TJ" # 2 array show
275
+ rb"|\((.*?)(?<!\\)\)\s*Tj" # 3 string show
276
+ rb"|(T\*|Td|TD)", # 4 positioning
277
+ re.DOTALL,
278
+ )
279
+ _IN_ARRAY = re.compile(rb"\((.*?)(?<!\\)\)", re.DOTALL)
280
+
281
+
282
+ def extract_pdf_by_cmap(path: Path) -> str | None:
283
+ """Text read through the document's own ToUnicode maps, or None if it has none."""
284
+ data = path.read_bytes()
285
+ objects = _objects(data)
286
+ by_name, two_byte = _font_maps(objects)
287
+ if not by_name:
288
+ return None
289
+
290
+ pieces: list[str] = []
291
+ for body in objects.values():
292
+ if b"Tj" not in body and b"TJ" not in body:
293
+ continue
294
+ stream = _object_stream(body)
295
+ if not stream or (b"Tj" not in stream and b"TJ" not in stream):
296
+ continue
297
+ cmap, wide = None, False
298
+ for m in _TEXT_OP.finditer(stream):
299
+ if m.group(1):
300
+ name = m.group(1).decode()
301
+ cmap, wide = by_name.get(name), two_byte.get(name, False)
302
+ elif m.group(2) is not None:
303
+ for s in _IN_ARRAY.finditer(m.group(2)):
304
+ pieces.append(_apply_cmap(_unescape_bytes(s.group(1)), cmap, wide))
305
+ elif m.group(3) is not None:
306
+ pieces.append(_apply_cmap(_unescape_bytes(m.group(3)), cmap, wide))
307
+ elif m.group(4):
308
+ pieces.append("\n")
309
+
310
+ if not pieces:
311
+ return None
312
+ text = "".join(pieces)
313
+ text = re.sub(r"[ \t]+", " ", text)
314
+ return re.sub(r"\n{3,}", "\n\n", text)
315
+
316
+
317
+ def _unescape_bytes(raw: bytes) -> bytes:
318
+ """PDF string escapes, as bytes, so a multi-byte font encoding survives intact."""
319
+ out, i = bytearray(), 0
320
+ simple = {0x6E: 10, 0x72: 13, 0x74: 9, 0x62: 8, 0x66: 12,
321
+ 0x28: 0x28, 0x29: 0x29, 0x5C: 0x5C}
322
+ while i < len(raw):
323
+ if raw[i] == 0x5C and i + 1 < len(raw):
324
+ nxt = raw[i + 1]
325
+ if nxt in simple:
326
+ out.append(simple[nxt]); i += 2; continue
327
+ if 0x30 <= nxt <= 0x37:
328
+ j, digits = i + 1, b""
329
+ while j < len(raw) and len(digits) < 3 and 0x30 <= raw[j] <= 0x37:
330
+ digits += bytes([raw[j]]); j += 1
331
+ out.append(int(digits, 8) & 0xFF); i = j; continue
332
+ i += 2; continue
333
+ out.append(raw[i]); i += 1
334
+ return bytes(out)
335
+
336
+
337
+ def _extract_pdf_by_shift(path: Path) -> str:
338
+ data = path.read_bytes()
339
+ chunks: list[str] = []
340
+ for raw in STREAM.findall(data):
341
+ body = raw
342
+ if body[:2] in (b"\x78\x9c", b"\x78\x01", b"\x78\xda"):
343
+ try:
344
+ body = zlib.decompress(body)
345
+ except Exception:
346
+ continue
347
+ if b"Tj" not in body and b"TJ" not in body:
348
+ continue
349
+ for line in body.split(b"\n"):
350
+ if b"Tj" not in line and b"TJ" not in line:
351
+ continue
352
+ piece = [_unescape(m.group(0)[1:-1]) for m in LITERAL.finditer(line)]
353
+ for m in HEXSTR.finditer(line):
354
+ try:
355
+ piece.append(bytes.fromhex(m.group(1).decode("ascii")).decode("utf-16-be", "replace"))
356
+ except Exception:
357
+ pass
358
+ if piece:
359
+ chunks.append("".join(piece))
360
+ text = " ".join(chunks)
361
+ text = re.sub(r"[\x00-\x1f]+", " ", text)
362
+ text = re.sub(r"\s{2,}", " ", text)
363
+ return re.sub(r"[!-~]{4,}", lambda m: _decode_token(m.group(0)), text)
364
+
365
+
366
+ def manual_text(name: str, cache_dir: Path | None = None) -> str:
367
+ """The decoded text of one manual, cached on disk."""
368
+ root = manuals_root()
369
+ matches = [p for p in root.glob("*.pdf") if name.lower() in p.stem.lower()]
370
+ if not matches:
371
+ available = ", ".join(p.stem for p in root.glob("*.pdf"))
372
+ raise NotFound(f"no manual matching {name!r}. Available: {available}")
373
+ path = matches[0]
374
+
375
+ from .knowledge import cache_root
376
+ import hashlib
377
+ root_cache = cache_root()
378
+ cache_base = (cache_dir or root_cache / 'legacy').resolve()
379
+ if not cache_base.is_relative_to(root_cache):
380
+ raise ValueError('Manual extraction cache must stay inside workspace reference storage')
381
+ key = hashlib.sha256(path.read_bytes()).hexdigest()
382
+ cache = cache_base / (key + '.txt')
383
+ if not cache.resolve().is_relative_to(root_cache):
384
+ raise ValueError('Linked manual extraction cache escapes reference storage')
385
+ try:
386
+ if cache.is_file() and cache.stat().st_mtime >= path.stat().st_mtime:
387
+ return cache.read_text(encoding="utf-8")
388
+ except OSError:
389
+ pass
390
+
391
+ text = extract_pdf(path)
392
+ try:
393
+ cache.parent.mkdir(parents=True, exist_ok=True)
394
+ cache.write_text(text, encoding="utf-8")
395
+ except OSError:
396
+ pass
397
+ return text
398
+
399
+
400
+ def search(term: str, name: str | None = None, limit: int = 5,
401
+ cache_dir: Path | None = None) -> list[dict]:
402
+ """Find a term across the manuals, with surrounding context.
403
+
404
+ A term's words are allowed to sit apart in the text: the PDFs draw each word as its own
405
+ operation, so a phrase has to be matched with whitespace between its parts or it never hits.
406
+ """
407
+ targets = list_manuals()
408
+ if name:
409
+ targets = [m for m in targets if name.lower() in m.name.lower() and m.readable]
410
+ else:
411
+ targets = [m for m in targets if m.readable]
412
+
413
+ pattern = re.compile(r"\W+".join(re.escape(w) for w in term.split()), re.IGNORECASE)
414
+ out: list[dict] = []
415
+ for manual in targets:
416
+ try:
417
+ text = manual_text(manual.name, cache_dir=cache_dir)
418
+ except Exception:
419
+ continue
420
+ hits = list(pattern.finditer(text))
421
+ if not hits:
422
+ continue
423
+ out.append({
424
+ "manual": manual.name,
425
+ "hits": len(hits),
426
+ "passages": [
427
+ text[max(0, h.start() - 200):h.end() + 400].strip()
428
+ for h in hits[:limit]
429
+ ],
430
+ })
431
+ return out
432
+
433
+
434
+ def extract_pdf(path: Path) -> str:
435
+ """Text from a PDF, preferring the document's own character maps.
436
+
437
+ The CMap path is exact and is tried first. The older heuristic - inflate, pull the drawn
438
+ strings, and guess at a +/-29 font shift - remains as a fallback, because some PDFs really do
439
+ use a shifted encoding and ship no map to read.
440
+ """
441
+ candidates: list[tuple[str, str]] = []
442
+
443
+ try:
444
+ by_cmap = extract_pdf_by_cmap(path)
445
+ if by_cmap and by_cmap.strip():
446
+ candidates.append(("cmap", by_cmap))
447
+ except Exception:
448
+ pass
449
+
450
+ try:
451
+ plain = _extract_pdf_plain(path)
452
+ if plain.strip():
453
+ candidates.append(("plain", plain))
454
+ except Exception:
455
+ pass
456
+
457
+ try:
458
+ shifted = _extract_pdf_by_shift(path)
459
+ if shifted.strip():
460
+ candidates.append(("shift", shifted))
461
+ except Exception:
462
+ pass
463
+
464
+ if not candidates:
465
+ return ""
466
+
467
+ # Judge by English: count the ordinary words in a sample. The three decoders differ enormously
468
+ # when the right one is chosen and barely at all when it is not, so this only has to be roughly
469
+ # right - but it does have to be about words, which the vowel-based _score is not.
470
+ return max(candidates, key=lambda pair: _english(pair[1]))[1]
471
+
472
+
473
+ #: Ordinary words, used only to decide which decoder produced readable text.
474
+ _ENGLISH = frozenset("""
475
+ the and for with this that are not from which function block variable type value input output
476
+ is of to in on be by or an as it at we you they can may must will would should has have been
477
+ when where what how all any each other more most such only same than then there their them
478
+ """.split())
479
+
480
+
481
+ def _english(text: str) -> float:
482
+ """Fraction of the words in a sample that are ordinary English words."""
483
+ words = re.findall(r"[A-Za-z]{3,}", text[:20000])
484
+ if not words:
485
+ return 0.0
486
+ return sum(1 for w in words if w.lower() in _ENGLISH) / len(words)
487
+
488
+
489
+ def _extract_pdf_plain(path: Path) -> str:
490
+ """The drawn strings decoded as latin-1, with no re-encoding guess applied.
491
+
492
+ This is what the shift heuristic is a correction FOR. Some documents are already plain and the
493
+ correction damages them - AN.MPIEC.01 reads at 24% this way and 0% with the shift - so the two
494
+ are offered as alternatives and the scorer picks.
495
+ """
496
+ data = path.read_bytes()
497
+ chunks: list[str] = []
498
+ for raw in STREAM.findall(data):
499
+ body = raw
500
+ if body[:2] in (b"\x78\x9c", b"\x78\x01", b"\x78\xda"):
501
+ try:
502
+ body = zlib.decompress(body)
503
+ except Exception:
504
+ continue
505
+ if b"Tj" not in body and b"TJ" not in body:
506
+ continue
507
+ # Walk the whole stream, not line by line: an operator and its operand are often on
508
+ # different lines, and a drawn string can contain a newline of its own.
509
+ for m in _TEXT_RUN.finditer(body):
510
+ piece = m.group(1) if m.group(1) is not None else m.group(2)
511
+ for s in _STRING_IN.finditer(piece):
512
+ chunks.append(_unescape(s.group(1)))
513
+ chunks.append("\n")
514
+ text = "".join(chunks)
515
+ text = re.sub(r"[ \t]{2,}", " ", text)
516
+ return re.sub(r"\n{3,}", "\n\n", text)
517
+
518
+ #: A drawn-string run: either the operand of Tj, or the array operand of TJ.
519
+ _TEXT_RUN = re.compile(rb"\((.*?)(?<!\\)\)\s*Tj|\[(.*?)\]\s*TJ", re.DOTALL)
520
+ _STRING_IN = re.compile(rb"\((.*?)(?<!\\)\)", re.DOTALL)