gem-mapping-studio 0.2.2__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.
File without changes
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """Extract PDF highlights and callouts using PyMuPDF.
3
+
4
+ For each highlight, PyMuPDF can give us the covered text directly via
5
+ page.get_textbox(rect) or via the annotation's get_text() method. It also
6
+ gives us better handling of Popup-linked callout notes.
7
+ """
8
+ import sys
9
+ import json
10
+ import fitz # PyMuPDF
11
+
12
+
13
+ def rect_from_quadpoints(quads):
14
+ """Given the 8-float quadpoints, return a fitz.Rect bounding box."""
15
+ if not quads:
16
+ return None
17
+ xs = [quads[i] for i in range(0, len(quads), 2)]
18
+ ys = [quads[i] for i in range(1, len(quads), 2)]
19
+ return fitz.Rect(min(xs), min(ys), max(xs), max(ys))
20
+
21
+
22
+ def main(pdf_path, output_json=None):
23
+ doc = fitz.open(pdf_path)
24
+ results = []
25
+
26
+ for page_num, page in enumerate(doc, start=1):
27
+ annot = page.first_annot
28
+ while annot is not None:
29
+ info = annot.info or {}
30
+ atype = annot.type # (type_id, type_name)
31
+ type_name = atype[1] if atype else "?"
32
+ contents = info.get("content", "") or ""
33
+ author = info.get("title", "") or ""
34
+
35
+ entry = {
36
+ "page": page_num,
37
+ "type": type_name,
38
+ "author": author,
39
+ "note": contents.strip() if contents else "",
40
+ "rect": list(annot.rect),
41
+ }
42
+
43
+ if type_name == "Highlight":
44
+ # Try multiple extraction strategies and pick the best
45
+ texts = []
46
+
47
+ # 1. Quadpoints-based: iterate the vertices in groups of 4
48
+ try:
49
+ verts = annot.vertices
50
+ if verts:
51
+ # vertices come as list of tuples; each highlight quad
52
+ # uses 4 points
53
+ for i in range(0, len(verts), 4):
54
+ quad = verts[i:i+4]
55
+ if len(quad) == 4:
56
+ xs = [p[0] for p in quad]
57
+ ys = [p[1] for p in quad]
58
+ r = fitz.Rect(min(xs), min(ys),
59
+ max(xs), max(ys))
60
+ t = page.get_textbox(r).strip()
61
+ if t:
62
+ texts.append(t)
63
+ except Exception:
64
+ pass
65
+
66
+ # 2. Fallback: use the annotation rect
67
+ if not texts:
68
+ try:
69
+ t = page.get_textbox(annot.rect).strip()
70
+ if t:
71
+ texts.append(t)
72
+ except Exception:
73
+ pass
74
+
75
+ highlighted = " ".join(texts).strip()
76
+ # Clean up the weird line breaks and excess whitespace
77
+ highlighted = " ".join(highlighted.split())
78
+ entry["highlighted_text"] = highlighted
79
+
80
+ results.append(entry)
81
+ annot = annot.next
82
+
83
+ # Filter out noise: empty highlights with no note
84
+ cleaned = []
85
+ for r in results:
86
+ if r["type"] == "Highlight":
87
+ if not r.get("highlighted_text") and not r.get("note"):
88
+ continue # skip truly empty
89
+ cleaned.append(r)
90
+
91
+ if output_json:
92
+ with open(output_json, "w") as f:
93
+ json.dump(cleaned, f, indent=2)
94
+ print(f"Wrote {len(cleaned)} annotations to {output_json}")
95
+ else:
96
+ for r in cleaned:
97
+ print(f"\n[Page {r['page']}] {r['type']} (author: {r['author']})")
98
+ if r.get("note"):
99
+ print(f" NOTE: {r['note']}")
100
+ if r.get("highlighted_text"):
101
+ print(f" TEXT: {r['highlighted_text']}")
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main(sys.argv[1],
106
+ output_json=sys.argv[2] if len(sys.argv) > 2 else None)
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """Extract PDF annotations (highlights and text-note callouts) from a PDF.
3
+
4
+ For each annotation on each page, report:
5
+ - annotation subtype (Highlight, Text, Popup, etc.)
6
+ - page number (1-indexed)
7
+ - the author of the annotation if present
8
+ - the 'Contents' field (what the annotator typed as a note)
9
+ - the quadpoints / rect (location on page)
10
+ - the text actually under the highlight, extracted by coordinates
11
+
12
+ This matters because a 'Highlight' annotation stores WHERE it is, not WHAT it
13
+ covers; the covered text has to be recovered from the page's text content
14
+ using the quadpoints.
15
+ """
16
+
17
+ import sys
18
+ import pypdf
19
+ from pypdf.generic import ArrayObject, NumberObject
20
+
21
+
22
+ def extract_text_under_quads(page, quadpoints):
23
+ """Given a page and the Highlight's /QuadPoints, extract the text under
24
+ each quad. QuadPoints come in groups of 8: (x1,y1, x2,y2, x3,y3, x4,y4)
25
+ describing the four corners of each rectangle covered by the highlight.
26
+
27
+ We use pypdf's visitor-based text extraction to collect only text whose
28
+ position falls inside any quad rectangle.
29
+ """
30
+ if not quadpoints:
31
+ return ""
32
+
33
+ # Group quadpoints into (x_min, y_min, x_max, y_max) rects.
34
+ rects = []
35
+ q = [float(v) for v in quadpoints]
36
+ for i in range(0, len(q), 8):
37
+ xs = q[i:i+8:2]
38
+ ys = q[i+1:i+8:2]
39
+ rects.append((min(xs), min(ys), max(xs), max(ys)))
40
+
41
+ collected = []
42
+
43
+ def visitor(text, cm, tm, fontDict, fontSize):
44
+ # tm is the text matrix; its e,f are the x,y position.
45
+ try:
46
+ x = tm[4]
47
+ y = tm[5]
48
+ except Exception:
49
+ return
50
+ for (xmin, ymin, xmax, ymax) in rects:
51
+ # Allow a small slack in y because tm gives the baseline, not the
52
+ # character box.
53
+ if xmin - 1 <= x <= xmax + 1 and ymin - 4 <= y <= ymax + 4:
54
+ if text and text.strip():
55
+ collected.append(text)
56
+ return
57
+
58
+ try:
59
+ page.extract_text(visitor_text=visitor)
60
+ except Exception as e:
61
+ return f"<extraction error: {e}>"
62
+
63
+ return " ".join(collected).strip()
64
+
65
+
66
+ def main(pdf_path):
67
+ reader = pypdf.PdfReader(pdf_path)
68
+ print(f"PDF: {pdf_path}")
69
+ print(f"Pages: {len(reader.pages)}")
70
+ print()
71
+
72
+ # First pass: find all Popup annotations, keyed by IndirectRef of the
73
+ # Popup object itself. A Highlight often has a /Popup pointer to a
74
+ # Popup annotation which in turn has /Contents (the actual callout text).
75
+ popups_by_ref = {}
76
+ for page_idx, page in enumerate(reader.pages):
77
+ if "/Annots" not in page:
78
+ continue
79
+ for annot_ref in page["/Annots"]:
80
+ annot = annot_ref.get_object()
81
+ subtype = annot.get("/Subtype")
82
+ if subtype == "/Popup":
83
+ popups_by_ref[annot_ref.idnum] = annot
84
+
85
+ annot_count = 0
86
+ for page_idx, page in enumerate(reader.pages):
87
+ if "/Annots" not in page:
88
+ continue
89
+
90
+ for annot_ref in page["/Annots"]:
91
+ annot = annot_ref.get_object()
92
+ subtype = annot.get("/Subtype", "?")
93
+
94
+ # We are most interested in Highlight and Text (callout/sticky).
95
+ # Popups are auxiliary; they'll be read via their parent Highlight.
96
+ if subtype not in ("/Highlight", "/Text", "/Underline",
97
+ "/Squiggly", "/StrikeOut"):
98
+ continue
99
+
100
+ annot_count += 1
101
+ contents = annot.get("/Contents", "")
102
+ author = annot.get("/T", "")
103
+ quads = annot.get("/QuadPoints", None)
104
+ rect = annot.get("/Rect", None)
105
+
106
+ # If Highlight has a Popup with Contents, prefer that text
107
+ popup_ref = annot.get("/Popup")
108
+ popup_contents = ""
109
+ if popup_ref is not None:
110
+ try:
111
+ popup = popup_ref.get_object()
112
+ popup_contents = popup.get("/Contents", "") or ""
113
+ except Exception:
114
+ pass
115
+
116
+ note_text = contents or popup_contents or ""
117
+
118
+ highlighted_text = ""
119
+ if subtype == "/Highlight" and quads is not None:
120
+ highlighted_text = extract_text_under_quads(page, quads)
121
+
122
+ print(f"--- Annotation #{annot_count} (page {page_idx+1}) ---")
123
+ print(f" subtype: {subtype}")
124
+ if author:
125
+ print(f" author: {author}")
126
+ if note_text:
127
+ print(f" note/callout: {note_text!r}")
128
+ if highlighted_text:
129
+ print(f" highlighted text: {highlighted_text!r}")
130
+ elif subtype == "/Text":
131
+ # Sticky note — no highlighted text, but has a location
132
+ if rect:
133
+ print(f" location (rect): {list(rect)}")
134
+ print()
135
+
136
+ print(f"Total annotations processed: {annot_count}")
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main(sys.argv[1])
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """Convert a Genetic Evidence Model annotation YAML to RDF (Turtle).
3
+
4
+ The emitted RDF is what the SHACL shapes in ``schema/genetic_evidence.shacl.ttl``
5
+ validate. Every ``GeneticEvidence`` item becomes a ``gem:GeneticEvidence``
6
+ node carrying its dimension values (as ``gem:`` IRIs for categorical
7
+ dimensions, literals for free-text ones) and its assertions, each with a
8
+ ``gem:sourceSpan`` blank node.
9
+
10
+ Design notes
11
+ ------------
12
+ * Field-name aliases are normalized: ``resolution``/``target_resolution`` and
13
+ source-span ``key_phrase``/``phrase`` (the v0 -> v1 protocol rename).
14
+ * Legacy method synonyms are mapped to canonical values
15
+ (``IN_VIVO_EXPERIMENT`` -> ``IN_VIVO`` etc.) so the shapes need only the
16
+ canonical enumeration.
17
+ * The explicit escape value ``not_applicable_or_omitted`` (used when a
18
+ curator considered a dimension and chose not to fill it) is emitted as
19
+ ``gem:NOT_APPLICABLE_OR_OMITTED``; the shapes accept it as "addressed".
20
+
21
+ Usage:
22
+ python3 extraction/yaml_to_rdf.py annotations/jossin2017.yaml [out.ttl]
23
+ python3 extraction/yaml_to_rdf.py annotations/*.yaml --stdout
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ import yaml
31
+
32
+ GEM = "https://w3id.org/genetic-evidence-model/"
33
+ NA = "NOT_APPLICABLE_OR_OMITTED"
34
+
35
+ # Categorical dimensions: YAML key -> gem: property local name. Values become
36
+ # gem:<VALUE> IRIs (drawn from the enumerations declared in the SHACL file).
37
+ CATEGORICAL = {
38
+ "knowledge_domain": "knowledgeDomain",
39
+ "method": "method",
40
+ "target_type": "targetType",
41
+ "resolution": "resolution",
42
+ "target_resolution": "resolution", # v1 alias
43
+ "credibility": "credibility",
44
+ "phenotype_scale": "phenotypeScale",
45
+ "variant_ascertainment": "variantAscertainment",
46
+ "measurement_target": "measurementTarget",
47
+ "gene_relation": "geneRelation",
48
+ "knockout_type": "knockoutType",
49
+ "mode_of_inheritance": "modeOfInheritance",
50
+ "penetrance": "penetrance",
51
+ }
52
+
53
+ # Free-text dimensions emitted as plain literals.
54
+ LITERAL_DIMS = {
55
+ "organism": "organism",
56
+ "target": "target",
57
+ "specificity_of_phenotype": "specificityOfPhenotype",
58
+ "subdomain": "subdomain", # enum values contain spaces ("Candidate Gene Study")
59
+ }
60
+
61
+ # A value safe to use as a Turtle IRI local name (no spaces/punctuation).
62
+ import re as _re
63
+ _TOKEN = _re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
64
+
65
+ # Boolean dimensions.
66
+ BOOLEAN_DIMS = {
67
+ "mendelian_segregation": "mendelianSegregation",
68
+ "exact_variant": "exactVariant",
69
+ "genetic_background_considered": "geneticBackgroundConsidered",
70
+ }
71
+
72
+ # Legacy method synonyms -> canonical value (dimensions.md "Backward compatibility").
73
+ METHOD_SYNONYMS = {
74
+ "IN_VIVO_EXPERIMENT": "IN_VIVO",
75
+ "IN_VITRO_EXPERIMENT": "IN_VITRO",
76
+ "BIOINFORMATICS_PREDICTION": "BIOINFORMATICS_INFERENCE",
77
+ }
78
+
79
+
80
+ def esc(text: str) -> str:
81
+ """Escape a string for a Turtle long-literal (triple-quoted)."""
82
+ return str(text).replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
83
+
84
+
85
+ def _values(raw):
86
+ """Normalise a YAML dimension value into a list of scalar strings."""
87
+ if raw is None:
88
+ return []
89
+ if isinstance(raw, list):
90
+ return [v for v in raw if v is not None]
91
+ if isinstance(raw, dict):
92
+ # e.g. hpo_terms: {annotated_as: not_applicable_or_omitted, ...}
93
+ if raw.get("annotated_as"):
94
+ return [raw["annotated_as"]]
95
+ return []
96
+ return [raw]
97
+
98
+
99
+ def _iri_value(prop: str, val) -> str:
100
+ """Map a scalar dimension value to a gem: IRI local name."""
101
+ s = str(val).strip()
102
+ if s.lower() == "not_applicable_or_omitted":
103
+ return NA
104
+ if prop == "method":
105
+ s = METHOD_SYNONYMS.get(s, s)
106
+ return s
107
+
108
+
109
+ def item_triples(item: dict, idx: int) -> list[str]:
110
+ """Return a list of Turtle blocks for one GeneticEvidence item."""
111
+ raw_id = str(item.get("id", f"item{idx}"))
112
+ node = f"gem:item_{raw_id.replace('.', '_').replace('-', '_')}"
113
+ item_props = [f'gem:itemId "{esc(raw_id)}"']
114
+ extra_blocks = [] # assertion + source-span nodes, declared separately
115
+
116
+ for key, prop in CATEGORICAL.items():
117
+ for v in _values(item.get(key)):
118
+ tok = _iri_value(prop, v)
119
+ if _TOKEN.match(tok):
120
+ item_props.append(f"gem:{prop} gem:{tok}")
121
+ else: # value is not a clean enum token; keep it as a literal
122
+ item_props.append(f'gem:{prop} """{esc(v)}"""')
123
+
124
+ for key, prop in LITERAL_DIMS.items():
125
+ if item.get(key) not in (None, ""):
126
+ item_props.append(f'gem:{prop} """{esc(item[key])}"""')
127
+
128
+ for key, prop in BOOLEAN_DIMS.items():
129
+ if item.get(key) is not None:
130
+ b = "true" if item[key] in (True, "true", "True") else "false"
131
+ item_props.append(f"gem:{prop} {b}")
132
+
133
+ for a_i, a in enumerate(item.get("assertions", []) or []):
134
+ an = f"{node}_a{a_i}"
135
+ span = a.get("source_span") or {}
136
+ phrase = span.get("phrase", span.get("key_phrase"))
137
+ page = span.get("page")
138
+ a_props = [f'gem:assertionId "{esc(a.get("id", f"{raw_id}.A{a_i}"))}"']
139
+ if a.get("statement"):
140
+ a_props.append(f'gem:statement """{esc(a["statement"])}"""')
141
+ if phrase is not None or page is not None:
142
+ sp_props = []
143
+ if page is not None:
144
+ sp_props.append(f"gem:page {int(page)}")
145
+ if phrase is not None:
146
+ sp_props.append(f'gem:phrase """{esc(phrase)}"""')
147
+ extra_blocks.append(
148
+ f"{an}_span a gem:SourceSpan ;\n "
149
+ + " ;\n ".join(sp_props) + " .")
150
+ a_props.append(f"gem:sourceSpan {an}_span")
151
+ extra_blocks.append(
152
+ f"{an} a gem:GeneticEvidenceAssertion ;\n "
153
+ + " ;\n ".join(a_props) + " .")
154
+ item_props.append(f"gem:assertion {an}")
155
+
156
+ item_block = (f"{node} a gem:GeneticEvidence ;\n "
157
+ + " ;\n ".join(item_props) + " .")
158
+ return [item_block] + extra_blocks
159
+
160
+
161
+ def convert(path: Path) -> str:
162
+ data = yaml.safe_load(path.read_text())
163
+ triples = [f"@prefix gem: <{GEM}> .",
164
+ f"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .",
165
+ f"# Generated from {path.name} by extraction/yaml_to_rdf.py", ""]
166
+ for idx, item in enumerate(data.get("evidence", []) or []):
167
+ triples.extend(item_triples(item, idx))
168
+ triples.append("")
169
+ return "\n".join(triples) + "\n"
170
+
171
+
172
+ def main(argv):
173
+ args = [a for a in argv if not a.startswith("--")]
174
+ to_stdout = "--stdout" in argv
175
+ if not args:
176
+ print(__doc__)
177
+ return 2
178
+ for src in args:
179
+ p = Path(src)
180
+ ttl = convert(p)
181
+ if to_stdout or len(args) == 1 and len(argv) == 1:
182
+ sys.stdout.write(ttl)
183
+ else:
184
+ out = p.with_suffix(".ttl")
185
+ out.write_text(ttl)
186
+ print(f"wrote {out}")
187
+ return 0
188
+
189
+
190
+ if __name__ == "__main__":
191
+ raise SystemExit(main(sys.argv[1:]))
File without changes
@@ -0,0 +1,58 @@
1
+ """Locate the repository's data and schema directories.
2
+
3
+ The UMLS crosswalk tooling is a repo-run data project: its inputs/outputs live
4
+ in ``data/umls/`` and the schema in ``schema/`` at the repo root, discovered by
5
+ walking up from this module. ``GEM_DATA_DIR`` overrides the data directory
6
+ (used by tests / alternate checkouts).
7
+
8
+ Standalone installs (``pip install forome-gem``, no repo checkout) fall back
9
+ to the synced reference copies packaged in ``forome.gem._reference`` for the
10
+ Semantic Network and the SHACL schema, and to the current working directory
11
+ as the default workspace (the Studio treats an empty directory as a fresh
12
+ workspace).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from pathlib import Path
18
+
19
+ _HERE = Path(__file__).resolve()
20
+
21
+
22
+ def _repo_root() -> Path | None:
23
+ """The repo checkout this module runs from, or None (standalone install)."""
24
+ for cand in _HERE.parents:
25
+ if (cand / "data" / "umls").is_dir() and (cand / "schema").is_dir():
26
+ return cand
27
+ for cand in _HERE.parents:
28
+ if (cand / "pyproject.toml").is_file():
29
+ return cand
30
+ return None
31
+
32
+
33
+ _REPO = _repo_root()
34
+ _PACKAGED = _HERE.parent.parent / "_reference" # forome/gem/_reference
35
+
36
+ # Kept for repo-mode consumers; in standalone mode it points at the package's
37
+ # parent purely so derived paths stay well-defined (they will not exist).
38
+ REPO_ROOT = _REPO if _REPO is not None else _HERE.parents[6] if len(_HERE.parents) > 6 else _HERE.parent
39
+
40
+ # The workspace: inventory / crosswalk / adjudications live here. ``GEM_DATA_DIR``
41
+ # points the tooling at any mapping directory (which may be empty, e.g. when
42
+ # building axes from scratch). Standalone default: the current directory.
43
+ if "GEM_DATA_DIR" in os.environ:
44
+ DATA_DIR = Path(os.environ["GEM_DATA_DIR"])
45
+ elif _REPO is not None:
46
+ DATA_DIR = _REPO / "data" / "umls"
47
+ else:
48
+ DATA_DIR = Path.cwd()
49
+
50
+ # Canonical reference data (the UMLS Semantic Network) resolves to the repo's
51
+ # copy when in a checkout — independent of the workspace, so type exploration
52
+ # works even when GEM_DATA_DIR is an empty directory — and to the packaged
53
+ # synced copy otherwise.
54
+ REFERENCE_DIR = (_REPO / "data" / "umls") if _REPO is not None else _PACKAGED
55
+ # The SHACL schema and dimensions.md: repo ``schema/`` or the packaged copies
56
+ # (the packaged layout is flat: the same filenames, no ``schema/`` prefix).
57
+ SCHEMA_DIR = (_REPO / "schema") if _REPO is not None else _PACKAGED
58
+ PAPER_SECTIONS = REPO_ROOT / "paper" / "sections"