cms-icd 0.1.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.
cms_icd/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """Version-aware access to official CMS ICD-10 materials.
2
+
3
+ The package separates release acquisition from parsing and keeps CM and PCS materials
4
+ independently lazy.
5
+ """
6
+
7
+ from .constants import ICD10_PCS_CHARACTERS
8
+ from .exceptions import (
9
+ AmbiguousReleaseError,
10
+ DownloadError,
11
+ ICDKnowledgeBaseError,
12
+ MaterialUnavailableError,
13
+ ParseError,
14
+ ReleaseUnavailableError,
15
+ )
16
+ from .gems import GEMKnowledgeBase, GEMSystemView
17
+ from .knowledge_base import (
18
+ ICD10CMKnowledgeBase,
19
+ ICD10KnowledgeBase,
20
+ ICD10PCSKnowledgeBase,
21
+ )
22
+ from .models import (
23
+ Code,
24
+ GEMChoiceList,
25
+ GEMDirection,
26
+ GEMEntry,
27
+ GEMMapping,
28
+ GEMProvenance,
29
+ GEMScenario,
30
+ Guideline,
31
+ InstructionalNote,
32
+ Release,
33
+ Term,
34
+ )
35
+ from .stores import GEMStore
36
+
37
+ __all__ = [
38
+ "ICD10_PCS_CHARACTERS",
39
+ "AmbiguousReleaseError",
40
+ "Code",
41
+ "DownloadError",
42
+ "GEMChoiceList",
43
+ "GEMDirection",
44
+ "GEMEntry",
45
+ "GEMKnowledgeBase",
46
+ "GEMMapping",
47
+ "GEMProvenance",
48
+ "GEMScenario",
49
+ "GEMStore",
50
+ "GEMSystemView",
51
+ "Guideline",
52
+ "ICD10CMKnowledgeBase",
53
+ "ICD10KnowledgeBase",
54
+ "ICD10PCSKnowledgeBase",
55
+ "ICDKnowledgeBaseError",
56
+ "InstructionalNote",
57
+ "MaterialUnavailableError",
58
+ "ParseError",
59
+ "Release",
60
+ "ReleaseUnavailableError",
61
+ "Term",
62
+ ]
cms_icd/constants.py ADDED
@@ -0,0 +1,4 @@
1
+ """Stable constants defined by the ICD code systems."""
2
+
3
+ # Ordered characters permitted in ICD-10-PCS codes; I and O are omitted.
4
+ ICD10_PCS_CHARACTERS = tuple("0123456789ABCDEFGHJKLMNPQRSTUVWXYZ")
cms_icd/exceptions.py ADDED
@@ -0,0 +1,27 @@
1
+ """Exceptions raised by :mod:`cms_icd`."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ICDKnowledgeBaseError(RuntimeError):
7
+ """Base exception for expected CMS ICD knowledge-base failures."""
8
+
9
+
10
+ class ReleaseUnavailableError(ICDKnowledgeBaseError):
11
+ """Raised when the requested CMS release cannot be resolved."""
12
+
13
+
14
+ class AmbiguousReleaseError(ICDKnowledgeBaseError):
15
+ """Raised when multiple CMS artifacts match one material selection."""
16
+
17
+
18
+ class MaterialUnavailableError(ICDKnowledgeBaseError):
19
+ """Raised when a release does not provide a requested material."""
20
+
21
+
22
+ class DownloadError(ICDKnowledgeBaseError):
23
+ """Raised when an official CMS artifact cannot be downloaded or validated."""
24
+
25
+
26
+ class ParseError(ICDKnowledgeBaseError):
27
+ """Raised when a CMS material does not match the expected structure."""
cms_icd/gems.py ADDED
@@ -0,0 +1,330 @@
1
+ """Lazy access to official CMS General Equivalence Mappings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from itertools import pairwise
7
+ from threading import Lock
8
+ from typing import TYPE_CHECKING, Self
9
+
10
+ from .models import GEMDirection, GEMProvenance, Release
11
+ from .parsers import parse_gems
12
+ from .sources import CMSProvider, DirectoryProvider, MaterialProvider
13
+ from .stores import GEMStore
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+
19
+ class GEMSystemView:
20
+ """Lazy bidirectional GEMs for one ICD-10 system (CM or PCS)."""
21
+
22
+ def __init__(
23
+ self,
24
+ provider: MaterialProvider | None,
25
+ system: str,
26
+ *,
27
+ icd9_to_icd10: GEMStore | None = None,
28
+ icd10_to_icd9: GEMStore | None = None,
29
+ correction_providers: tuple[MaterialProvider, ...] = (),
30
+ ) -> None:
31
+ if system not in {"cm", "pcs"}:
32
+ raise ValueError(f"Unsupported GEM system: {system!r}")
33
+ self._provider = provider
34
+ self._correction_providers = correction_providers
35
+ self.system = system
36
+ self._stores = {
37
+ GEMDirection.ICD9_TO_ICD10: icd9_to_icd10,
38
+ GEMDirection.ICD10_TO_ICD9: icd10_to_icd9,
39
+ }
40
+ self._locks = {direction: Lock() for direction in GEMDirection}
41
+
42
+ @classmethod
43
+ def from_stores(
44
+ cls,
45
+ system: str,
46
+ *,
47
+ icd9_to_icd10: GEMStore | None = None,
48
+ icd10_to_icd9: GEMStore | None = None,
49
+ ) -> Self:
50
+ """Construct a view from prebuilt stores for custom sources or tests."""
51
+ return cls(
52
+ None,
53
+ system,
54
+ icd9_to_icd10=icd9_to_icd10,
55
+ icd10_to_icd9=icd10_to_icd9,
56
+ )
57
+
58
+ @property
59
+ def release(self) -> Release | None:
60
+ """Return release metadata, if this view is provider-backed."""
61
+ return self._provider.release if self._provider is not None else None
62
+
63
+ def _load(self, direction: GEMDirection) -> GEMStore:
64
+ store = self._stores[direction]
65
+ if store is None:
66
+ with self._locks[direction]:
67
+ store = self._stores[direction]
68
+ if store is None:
69
+ if self._provider is None:
70
+ raise RuntimeError(
71
+ f"{type(self).__name__} has no provider for unloaded GEMs"
72
+ )
73
+ store = self._load_provider(self._provider, direction)
74
+ if self._correction_providers:
75
+ stores = [store]
76
+ target_universes = [
77
+ set(
78
+ self._load_provider(
79
+ self._provider, _opposite(direction)
80
+ )
81
+ )
82
+ ]
83
+ for provider in self._correction_providers:
84
+ stores.append(self._load_provider(provider, direction))
85
+ target_universes.append(
86
+ set(self._load_provider(provider, _opposite(direction)))
87
+ )
88
+ store = _backport_corrections(stores, target_universes)
89
+ self._stores[direction] = store
90
+ return store
91
+
92
+ def _load_provider(
93
+ self, provider: MaterialProvider, direction: GEMDirection
94
+ ) -> GEMStore:
95
+ return parse_gems(
96
+ provider.paths(self.system, "gems"),
97
+ system=self.system,
98
+ direction=direction,
99
+ release=provider.release,
100
+ )
101
+
102
+ @property
103
+ def icd9_to_icd10(self) -> GEMStore:
104
+ """Return mappings from ICD-9-CM to ICD-10-CM or ICD-10-PCS."""
105
+ return self._load(GEMDirection.ICD9_TO_ICD10)
106
+
107
+ @property
108
+ def icd10_to_icd9(self) -> GEMStore:
109
+ """Return mappings from ICD-10-CM or ICD-10-PCS to ICD-9-CM."""
110
+ return self._load(GEMDirection.ICD10_TO_ICD9)
111
+
112
+ def __repr__(self) -> str:
113
+ """Return a representation without loading mappings."""
114
+ loaded = [
115
+ direction.value
116
+ for direction, store in self._stores.items()
117
+ if store is not None
118
+ ]
119
+ return (
120
+ f"GEMSystemView(system={self.system!r}, release={self.release!r}, "
121
+ f"loaded={loaded!r})"
122
+ )
123
+
124
+
125
+ class GEMKnowledgeBase:
126
+ """A CMS fiscal-year GEM release with independently lazy CM and PCS views."""
127
+
128
+ def __init__(
129
+ self,
130
+ provider: MaterialProvider,
131
+ *,
132
+ correction_providers: tuple[MaterialProvider, ...] = (),
133
+ ) -> None:
134
+ self._provider = provider
135
+ self._correction_providers = correction_providers
136
+ self._cm: GEMSystemView | None = None
137
+ self._pcs: GEMSystemView | None = None
138
+
139
+ @classmethod
140
+ def from_cms(
141
+ cls,
142
+ fiscal_year: int,
143
+ *,
144
+ cache_dir: str | Path | None = None,
145
+ offline: bool = False,
146
+ ) -> Self:
147
+ """Create a lazy GEM selector for an official CMS fiscal year.
148
+
149
+ Args:
150
+ fiscal_year: Official CMS GEM fiscal year.
151
+ cache_dir: Persistent artifact cache directory. ``None`` uses the
152
+ platform default cache directory.
153
+ offline: Require the catalog and artifacts to already be cached.
154
+ """
155
+ release = Release(fiscal_year, date(fiscal_year - 1, 10, 1))
156
+ return cls(
157
+ CMSProvider(
158
+ release,
159
+ cache_dir=cache_dir,
160
+ offline=offline,
161
+ )
162
+ )
163
+
164
+ @classmethod
165
+ def corrected_from_cms(
166
+ cls,
167
+ fiscal_year: int,
168
+ *,
169
+ corrections_through_fiscal_year: int = 2018,
170
+ cache_dir: str | Path | None = None,
171
+ offline: bool = False,
172
+ ) -> Self:
173
+ """Create GEMs using historical vocabulary and later safe corrections.
174
+
175
+ Each source remains on the requested fiscal year's vocabulary. Later complete
176
+ row sets are adopted only until that source encounters an introduced or retired
177
+ source/target code. Corrections are reviewed through FY2018 by default, the last
178
+ CMS GEM release.
179
+
180
+ Args:
181
+ fiscal_year: Historical vocabulary fiscal year.
182
+ corrections_through_fiscal_year: Last GEM release considered for safe
183
+ corrections.
184
+ cache_dir: Persistent artifact cache directory shared by all releases.
185
+ ``None`` uses the platform default cache directory.
186
+ offline: Require the catalog and artifacts to already be cached.
187
+ """
188
+ if corrections_through_fiscal_year < fiscal_year:
189
+ raise ValueError(
190
+ "corrections_through_fiscal_year must not precede fiscal_year"
191
+ )
192
+
193
+ def provider(year: int) -> CMSProvider:
194
+ return CMSProvider(
195
+ Release(year, date(year - 1, 10, 1)),
196
+ cache_dir=cache_dir,
197
+ offline=offline,
198
+ )
199
+
200
+ return cls(
201
+ provider(fiscal_year),
202
+ correction_providers=tuple(
203
+ provider(year)
204
+ for year in range(fiscal_year + 1, corrections_through_fiscal_year + 1)
205
+ ),
206
+ )
207
+
208
+ @classmethod
209
+ def from_directory(
210
+ cls,
211
+ directory: str | Path,
212
+ *,
213
+ fiscal_year: int,
214
+ ) -> Self:
215
+ """Create a knowledge base from locally supplied CMS-format GEM files."""
216
+ release = Release(fiscal_year, date(fiscal_year - 1, 10, 1))
217
+ return cls(DirectoryProvider(directory, release))
218
+
219
+ @property
220
+ def release(self) -> Release:
221
+ """Return the selected CMS GEM release."""
222
+ return self._provider.release
223
+
224
+ @property
225
+ def cm(self) -> GEMSystemView:
226
+ """Return the lazy diagnosis GEM view."""
227
+ if self._cm is None:
228
+ self._cm = GEMSystemView(
229
+ self._provider,
230
+ "cm",
231
+ correction_providers=self._correction_providers,
232
+ )
233
+ return self._cm
234
+
235
+ @property
236
+ def pcs(self) -> GEMSystemView:
237
+ """Return the lazy procedure GEM view."""
238
+ if self._pcs is None:
239
+ self._pcs = GEMSystemView(
240
+ self._provider,
241
+ "pcs",
242
+ correction_providers=self._correction_providers,
243
+ )
244
+ return self._pcs
245
+
246
+ def __repr__(self) -> str:
247
+ """Return a representation without acquiring any GEM material."""
248
+ loaded = [
249
+ name
250
+ for name, view in (("cm", self._cm), ("pcs", self._pcs))
251
+ if view is not None
252
+ ]
253
+ corrections_through = (
254
+ self._correction_providers[-1].release
255
+ if self._correction_providers
256
+ else None
257
+ )
258
+ return (
259
+ f"GEMKnowledgeBase(release={self.release!r}, "
260
+ f"corrections_through={corrections_through!r}, loaded={loaded!r})"
261
+ )
262
+
263
+
264
+ def _opposite(direction: GEMDirection) -> GEMDirection:
265
+ if direction is GEMDirection.ICD9_TO_ICD10:
266
+ return GEMDirection.ICD10_TO_ICD9
267
+ return GEMDirection.ICD9_TO_ICD10
268
+
269
+
270
+ def _targets(entries: tuple) -> set[str]:
271
+ return {entry.target for entry in entries if entry.target is not None}
272
+
273
+
274
+ def _backport_corrections(
275
+ stores: list[GEMStore], target_universes: list[set[str]]
276
+ ) -> GEMStore:
277
+ """Backport correction-only row sets without crossing code lifecycle changes."""
278
+ if len(stores) != len(target_universes) or not stores:
279
+ raise ValueError("A target universe is required for every GEM store")
280
+ base = stores[0]
281
+ if base.release is None or any(store.release is None for store in stores):
282
+ raise ValueError("Retrospective correction requires release metadata")
283
+ base_targets = target_universes[0]
284
+ values = dict(base.items())
285
+ selected = dict.fromkeys(base, base.release)
286
+ blocked: dict[str, Release] = {}
287
+
288
+ for index, (old, new) in enumerate(pairwise(stores)):
289
+ old_universe = target_universes[index]
290
+ new_universe = target_universes[index + 1]
291
+ introduced = new_universe - old_universe
292
+ retired = old_universe - new_universe
293
+ for source in base:
294
+ if source in blocked:
295
+ continue
296
+ if source not in old or source not in new:
297
+ blocked[source] = new.release
298
+ continue
299
+ old_entries = old[source]
300
+ new_entries = new[source]
301
+ if old_entries == new_entries:
302
+ continue
303
+ old_targets = _targets(old_entries)
304
+ new_targets = _targets(new_entries)
305
+ lifecycle = bool((old_targets | new_targets) & (introduced | retired))
306
+ historically_compatible = new_targets <= base_targets
307
+ lineage_matches = values[source] == old_entries
308
+ if lifecycle or not historically_compatible or not lineage_matches:
309
+ blocked[source] = new.release
310
+ continue
311
+ values[source] = new_entries
312
+ selected[source] = new.release
313
+
314
+ reviewed = stores[-1].release
315
+ provenance = {
316
+ source: GEMProvenance(
317
+ vocabulary_release=base.release,
318
+ selected_mapping_release=selected[source],
319
+ reviewed_through_release=reviewed,
320
+ blocked_by_code_lifecycle_release=blocked.get(source),
321
+ )
322
+ for source in base
323
+ }
324
+ return GEMStore(
325
+ values,
326
+ system=base.system,
327
+ direction=base.direction,
328
+ release=base.release,
329
+ provenance=provenance,
330
+ )
cms_icd/guidelines.py ADDED
@@ -0,0 +1,216 @@
1
+ """Parsing for official ICD-10 coding-guideline PDFs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pypdf import PdfReader
9
+
10
+ from .exceptions import ParseError
11
+ from .models import Guideline
12
+ from .stores import GuidelineStore
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Iterator, Sequence
16
+ from pathlib import Path
17
+
18
+ from pypdf import PageObject
19
+ from pypdf.generic import Destination
20
+
21
+
22
+ _SECTION = re.compile(r"Section\s+(IV|I{1,3})\b", re.I)
23
+ _SUBSECTION = re.compile(r"^([A-Z])\.\s")
24
+ _NUMBER = re.compile(r"^(\d+)\.\s")
25
+ _PAGE_FOOTER = re.compile(r"^Page\s+\d+\s+of\s+\d+$", re.I)
26
+ _CM_RUNNING_FOOTER = re.compile(
27
+ r"\s*ICD\s*-\s*10\s*-\s*CM\s+Official\s+Guidelines\s+for\s+Coding\s+and"
28
+ r"\s+Reporting\s+FY\s+\d{4}\s+Page\s+\d+\s+of\s+\d+\s*",
29
+ re.I,
30
+ )
31
+
32
+
33
+ def _text_position(
34
+ text_matrix: Sequence[float], user_matrix: Sequence[float]
35
+ ) -> tuple[float, float]:
36
+ """Return text-space translation transformed into page coordinates."""
37
+ x = text_matrix[4] * user_matrix[0] + text_matrix[5] * user_matrix[2]
38
+ y = text_matrix[4] * user_matrix[1] + text_matrix[5] * user_matrix[3]
39
+ return x + user_matrix[4], y + user_matrix[5]
40
+
41
+
42
+ def _page_text(page: PageObject) -> str:
43
+ footer_text: set[str] = set()
44
+ threshold = float(page.mediabox.height) * 0.08
45
+
46
+ def visit_text(
47
+ text: str,
48
+ user_matrix: Sequence[float],
49
+ text_matrix: Sequence[float],
50
+ _font: object,
51
+ _font_size: float,
52
+ ) -> None:
53
+ _, y = _text_position(text_matrix, user_matrix)
54
+ item = text.strip()
55
+ if item and 0 <= y <= threshold and _PAGE_FOOTER.match(item):
56
+ footer_text.add(item)
57
+
58
+ text = page.extract_text(visitor_text=visit_text) or ""
59
+ text = _CM_RUNNING_FOOTER.sub("", text)
60
+ for item in footer_text:
61
+ text = text.replace(item, "", 1)
62
+ text = re.sub(
63
+ r"\bICD\s*-\s*10\s*-\s*(CM|PCS)\b",
64
+ lambda match: f"ICD-10-{match.group(1).upper()}",
65
+ text,
66
+ flags=re.I,
67
+ )
68
+ text = re.sub(r"(?<=\d)\s+(st|nd|rd|th)\b", r"\1", text) # codespell:ignore nd
69
+ text = re.sub(r"\b([A-Z])\s+(?=\d{2}(?:\d|\.))", r"\1", text)
70
+ # Some CMS fonts place artificial word boundaries inside these words.
71
+ text = text.replace("Tabular L ist", "Tabular List") # codespell:ignore ist
72
+ text = re.sub(r"\bap\s+propriate\b", "appropriate", text)
73
+ text = re.sub(r"\bW\s+hen\b", "When", text)
74
+ text = re.sub(r"\bw\s+hen\b", "when", text)
75
+ return re.sub(r"^(\d+\.)\s*\n\s*", r"\n\1 ", text, flags=re.M).strip()
76
+
77
+
78
+ def _outline_entries(
79
+ document: PdfReader,
80
+ ) -> Iterator[tuple[int, str, int]]:
81
+ def walk(
82
+ items: Sequence[Destination | list[Destination]], level: int
83
+ ) -> Iterator[tuple[int, str, int]]:
84
+ for item in items:
85
+ if isinstance(item, list):
86
+ yield from walk(item, level + 1)
87
+ continue
88
+ page_number = document.get_destination_page_number(item)
89
+ if page_number is None:
90
+ raise ParseError(f"Guideline outline destination has no page: {item!s}")
91
+ yield level, str(item.title), page_number + 1
92
+
93
+ yield from walk(document.outline, 1)
94
+
95
+
96
+ def _strip_header(title: str, content: str) -> str:
97
+ words = [re.escape(word) for word in title.split()]
98
+ if not words:
99
+ return content
100
+ return re.sub(
101
+ r"^.*?" + r"\s+".join(words), "", content, count=1, flags=re.I | re.S
102
+ ).strip()
103
+
104
+
105
+ def _structured_cm_guidelines(document: PdfReader, path: str | Path) -> GuidelineStore:
106
+ entries: list[dict[str, object]] = []
107
+ current_section: str | None = None
108
+ current_subsection: str | None = None
109
+ for level, raw_title, page_number in _outline_entries(document):
110
+ if level == 1 and (match := _SECTION.search(raw_title)):
111
+ current_section = match.group(1).upper()
112
+ current_subsection = None
113
+ title = re.sub(
114
+ r"Section\s+(?:IV|I{1,3})\.\s*", "", raw_title, count=1, flags=re.I
115
+ ).strip()
116
+ entries.append(
117
+ {
118
+ "key": current_section,
119
+ "title": title,
120
+ "page": page_number,
121
+ "level": 1,
122
+ "raw_title": raw_title,
123
+ }
124
+ )
125
+ elif level == 2 and current_section and (match := _SUBSECTION.match(raw_title)):
126
+ current_subsection = f"{current_section}.{match.group(1)}"
127
+ entries.append(
128
+ {
129
+ "key": current_subsection,
130
+ "title": raw_title[match.end() :].strip(),
131
+ "page": page_number,
132
+ "level": 2,
133
+ "raw_title": raw_title,
134
+ }
135
+ )
136
+ elif level == 3 and current_subsection and (match := _NUMBER.match(raw_title)):
137
+ entries.append(
138
+ {
139
+ "key": f"{current_subsection}.{match.group(1)}",
140
+ "title": raw_title[match.end() :].strip(),
141
+ "page": page_number,
142
+ "level": 3,
143
+ "raw_title": raw_title,
144
+ }
145
+ )
146
+ if not entries:
147
+ raise ParseError(f"No structured CM guideline outline found in {path}")
148
+ for index, entry in enumerate(entries):
149
+ entry["leaf"] = index == len(entries) - 1 or int(
150
+ entries[index + 1]["level"]
151
+ ) <= int(entry["level"])
152
+ first_page = int(entries[0]["page"])
153
+ full_text = "\n".join(
154
+ _page_text(document.pages[number])
155
+ for number in range(first_page - 1, len(document.pages))
156
+ )
157
+ search_from = 0
158
+ for entry in entries:
159
+ words = str(entry["raw_title"]).split()[:8]
160
+ heading = "".join(words)
161
+ pattern = r"\s*".join(re.escape(character) for character in heading)
162
+ match = re.search(pattern, full_text[search_from:], re.I | re.M)
163
+ entry["position"] = search_from + match.start() if match else None
164
+ if match:
165
+ search_from = int(entry["position"])
166
+ titles = {str(entry["key"]): str(entry["title"]) for entry in entries}
167
+ guidelines: dict[str, Guideline] = {}
168
+ preambles: dict[str, str] = {}
169
+ for index, entry in enumerate(entries):
170
+ position = entry["position"]
171
+ if position is None:
172
+ continue
173
+ later = [item for item in entries[index + 1 :] if item["position"] is not None]
174
+ end = int(later[0]["position"]) if later else len(full_text)
175
+ content = full_text[int(position) : end].strip()
176
+ key = str(entry["key"])
177
+ if entry["leaf"]:
178
+ guidelines[key] = Guideline(
179
+ id=key.replace(".", "_"),
180
+ number=key,
181
+ title=str(entry["title"]),
182
+ content=content,
183
+ )
184
+ else:
185
+ body = _strip_header(str(entry["title"]), content)
186
+ if body:
187
+ preambles[key] = body
188
+ return GuidelineStore(guidelines, titles, preambles)
189
+
190
+
191
+ def parse_guidelines(path: str | Path, *, system: str) -> GuidelineStore:
192
+ """Parse an official coding-guidelines PDF.
193
+
194
+ CM PDFs receive dotted section keys. PCS PDFs, whose outlines vary more by release,
195
+ are exposed as one deterministic ``document`` guideline.
196
+ """
197
+ if system not in {"cm", "pcs"}:
198
+ raise ValueError(f"Unsupported guideline system: {system!r}")
199
+ try:
200
+ document = PdfReader(path)
201
+ except Exception as exc:
202
+ raise ParseError(
203
+ f"Unable to open ICD-10-{system.upper()} guidelines {path}: {exc}"
204
+ ) from exc
205
+ try:
206
+ if system == "pcs":
207
+ content = "\n".join(_page_text(page) for page in document.pages)
208
+ guideline = Guideline(
209
+ "document", "document", "Official Guidelines", content
210
+ )
211
+ return GuidelineStore(
212
+ {"document": guideline}, {"document": guideline.title}
213
+ )
214
+ return _structured_cm_guidelines(document, path)
215
+ finally:
216
+ document.close()