Spaces:
Running
Running
unknown
Claude Opus 5.5
Journal Formatter and Journal Finder as native tabs of the suite (work in progress)
7ee2a84 Download formatmatch/template.py from Rasheed/Test_AI_writing: direct link, hf CLI and curl.
- Browser
- Download file 26 kB
-
https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/formatmatch/template.py
- Command line
-
hf download hf://spaces/Rasheed/Test_AI_writing/formatmatch/template.py
-
curl -L -o template.py https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/formatmatch/template.py
26 kB
| """Learn a format from a template / sample paper (DOCX or PDF).""" | |
| from __future__ import annotations | |
| import collections | |
| import copy | |
| import io | |
| import re | |
| from dataclasses import dataclass, field | |
| from typing import Dict, List, Optional | |
| from docx.oxml.ns import qn | |
| from lxml import etree | |
| from . import model as M | |
| from .classify import CAPTION_RE, KEYWORDS_RE, ABSTRACT_RE, NUM_RE, strip_heading_number | |
| from .refs import BRACKET_RE, NARR_RE, PAREN_RE, parse_reference | |
| TWIP_CM = 2.54 / 1440 | |
| class TemplateProfile: | |
| data: bytes | |
| exemplars: Dict[str, dict] = field(default_factory=dict) | |
| sections: List[str] = field(default_factory=list) # sectPr XML strings, in order | |
| front_section: Optional[str] = None # sectPr closing the front matter (if any) | |
| table_tblPr: Optional[str] = None | |
| found: List[str] = field(default_factory=list) | |
| # ------------------------------------------------------------------ helpers | |
| def _x(el) -> Optional[str]: | |
| return etree.tostring(el, encoding="unicode") if el is not None else None | |
| def _empty_rpr(): | |
| return etree.Element(qn("w:rPr")) | |
| def _dominant_rpr(p_el, skip_label: bool = False): | |
| """rPr of the run carrying most text (optionally ignoring the first label run). | |
| A run without its own rPr yields an *empty* rPr (plain text), never the label's formatting. | |
| """ | |
| best, best_len = None, -1 | |
| first, seen_first = None, False | |
| for r in p_el.iter(qn("w:r")): | |
| t = "".join(x.text or "" for x in r.iter(qn("w:t"))) | |
| if not t.strip(): | |
| continue | |
| rpr = r.find(qn("w:rPr")) | |
| rpr = rpr if rpr is not None else _empty_rpr() | |
| if not seen_first: | |
| first, seen_first = rpr, True | |
| if skip_label and not re.search(r"[:.—–\-|]\s*\S", t): # label run (text continues in other runs) | |
| continue | |
| if len(t) > best_len: | |
| best, best_len = rpr, len(t) | |
| return (best if best is not None else first), first | |
| _DROP_PPR = ("framePr", "sectPr", "pageBreakBefore", "suppressLineNumbers") | |
| _DROP_RPR = ("vanish", "specVanish", "webHidden") | |
| def sanitize_ppr(xml): | |
| """Exemplar paragraph properties without absolute positioning / page breaks / section breaks.""" | |
| if not xml: | |
| return xml | |
| el = etree.fromstring(xml) | |
| for tag in _DROP_PPR: | |
| for x in el.findall(qn("w:" + tag)): | |
| el.remove(x) | |
| rpr = el.find(qn("w:rPr")) | |
| if rpr is not None: | |
| el.remove(rpr) | |
| return etree.tostring(el, encoding="unicode") | |
| def sanitize_rpr(xml): | |
| """Exemplar run properties without hidden text, placeholder styling or white text.""" | |
| if xml is None: | |
| return xml | |
| el = etree.fromstring(xml) | |
| for tag in _DROP_RPR: | |
| for x in el.findall(qn("w:" + tag)): | |
| el.remove(x) | |
| rs = el.find(qn("w:rStyle")) | |
| if rs is not None and "placeholder" in (rs.get(qn("w:val")) or "").lower(): | |
| el.remove(rs) | |
| col = el.find(qn("w:color")) | |
| if col is not None and (col.get(qn("w:val")) or "").upper() in ("FFFFFF", "AUTO_WHITE"): | |
| el.remove(col) | |
| return etree.tostring(el, encoding="unicode") | |
| def _has_numpr(p_el, doc_styles) -> bool: | |
| ppr = p_el.find(qn("w:pPr")) | |
| if ppr is not None and ppr.find(qn("w:numPr")) is not None: | |
| return True | |
| sid = ppr.find(qn("w:pStyle")).get(qn("w:val")) if ppr is not None and ppr.find(qn("w:pStyle")) is not None else None | |
| seen = 0 | |
| while sid and seen < 10: | |
| st = doc_styles.get(sid) | |
| if st is None: | |
| break | |
| sp = st.find(qn("w:pPr")) | |
| if sp is not None and sp.find(qn("w:numPr")) is not None: | |
| num = sp.find(qn("w:numPr")).find(qn("w:numId")) | |
| return num is None or num.get(qn("w:val")) != "0" | |
| b = st.find(qn("w:basedOn")) | |
| sid = b.get(qn("w:val")) if b is not None else None | |
| seen += 1 | |
| return False | |
| def _caps_in_style(p_el, rpr, doc_styles) -> bool: | |
| for el in [rpr]: | |
| if el is not None and (el.find(qn("w:caps")) is not None or el.find(qn("w:smallCaps")) is not None): | |
| return True | |
| ppr = p_el.find(qn("w:pPr")) | |
| sid = ppr.find(qn("w:pStyle")).get(qn("w:val")) if ppr is not None and ppr.find(qn("w:pStyle")) is not None else None | |
| seen = 0 | |
| while sid and seen < 10: | |
| st = doc_styles.get(sid) | |
| if st is None: | |
| break | |
| r = st.find(qn("w:rPr")) | |
| if r is not None and (r.find(qn("w:caps")) is not None or r.find(qn("w:smallCaps")) is not None): | |
| return True | |
| b = st.find(qn("w:basedOn")) | |
| sid = b.get(qn("w:val")) if b is not None else None | |
| seen += 1 | |
| return False | |
| def text_case(t: str) -> str: | |
| letters = [c for c in t if c.isalpha()] | |
| if letters and all(c.isupper() for c in letters): | |
| return "upper" | |
| words = [w for w in re.findall(r"[A-Za-z][\w'-]*", t)] | |
| big = [w for w in words if len(w) > 3] | |
| if big and sum(w[0].isupper() for w in big) / len(big) > 0.8 and len(big) > 1: | |
| return "title" | |
| if words and words[0][0].isupper(): | |
| return "sentence" | |
| return "as-is" | |
| def heading_scheme(texts: Dict[int, List[str]]): | |
| """From original heading texts infer scheme + trailing dots.""" | |
| out = {} | |
| l1 = texts.get(1, []) | |
| l2 = texts.get(2, []) | |
| if any(re.match(r"^\s*[IVX]+\.\s", t) for t in l1): | |
| out["scheme"] = "ieee" | |
| elif any(re.match(r"^\s*\d+\.?\s", t) for t in l1): | |
| out["scheme"] = "decimal" | |
| out["decimal_trailing_dot"] = any(re.match(r"^\s*\d+\.\s", t) for t in l1) | |
| if l2: | |
| out["subsection_trailing_dot"] = any(re.match(r"^\s*\d+\.\d+\.\s", t) for t in l2) | |
| elif l1: | |
| out["scheme"] = "none" | |
| return out | |
| def detect_ref_style(texts: List[str]) -> Optional[str]: | |
| texts = [t for t in texts if len(t) > 25][:15] | |
| if not texts: | |
| return None | |
| score = collections.Counter() | |
| for t in texts: | |
| s = t.strip() | |
| bracket = bool(re.match(r"^\[\d+\]", s)) | |
| numdot = bool(re.match(r"^\d+\.\s", s)) | |
| body = re.sub(r"^\s*(?:\[\d+\]|\d+\.)\s*", "", s) | |
| if bracket and re.search(r"[“\"].+?,[”\"]", body): | |
| score["ieee"] += 2 | |
| elif bracket: | |
| score["elsevier-numeric"] += 1 | |
| if re.search(r"\(\d{4}\)\s*\d", body): | |
| score["elsevier-numeric"] += 1 | |
| if re.match(r"^[^:]{3,200}:\s", body) and re.search(r"\((?:19|20)\d{2}\)\.?\s*(?:https?://\S+)?$", body): | |
| score["lncs"] += 2 | |
| if re.search(r"\b(?:19|20)\d{2};\s*\d", body): | |
| score["vancouver"] += 2 | |
| if re.search(r"^[^;]+,\s*[A-Z]\.(?:[A-Z]\.)*;\s", body): | |
| score["mdpi"] += 2 | |
| if re.search(r"\((?:19|20)\d{2}[a-z]?\)\.\s", body) and re.search(r",\s*&\s|, [A-Z]\. \(", body): | |
| score["apa"] += 2 | |
| elif re.search(r"\((?:19|20)\d{2}[a-z]?\)\.\s", body): | |
| score["apa"] += 1 | |
| if re.search(r"\((?:19|20)\d{2}[a-z]?\)\s*[‘']", body): | |
| score["harvard"] += 2 | |
| if re.search(r"\((?:19|20)\d{2}[a-z]?\),\s*[“\"]", body) or re.search(r"\bVol\.\s*\d+\s+No\.", body): | |
| score["emerald-harvard"] += 2 | |
| if re.match(r"^[A-Z][\w'-]+\s[A-Z]{1,3}(?:,|\s\()", body) and re.search(r"\((?:19|20)\d{2}\)\s[A-Z]", body): | |
| score["springer-basic" if not (bracket or numdot) else "springer-vancouver"] += 2 | |
| if re.search(r"\.\s(?:19|20)\d{2}\.\s[“\"]", body): | |
| score["chicago"] += 2 | |
| if not score: | |
| return None | |
| return score.most_common(1)[0][0] | |
| def detect_citation_mode_text(text: str) -> Optional[str]: | |
| nb = len(BRACKET_RE.findall(text)) | |
| na = len(PAREN_RE.findall(text)) + len(NARR_RE.findall(text)) | |
| if nb == na == 0: | |
| return None | |
| if nb >= na: | |
| return "numeric" | |
| if re.search(r"\([A-Z][\w-]+(?: et al\.)? (?:19|20)\d{2}[a-z]?[;)]", text) and not re.search( | |
| r"\([A-Z][\w-]+(?: et al\.)?, (?:19|20)\d{2}", text): | |
| return "author-year-nocomma" | |
| return "author-year" | |
| def _label_info(text: str, regex) -> Optional[dict]: | |
| m = regex.match(text) | |
| if not m: | |
| return None | |
| rest = m.group("rest") | |
| lab = text[: len(text) - len(rest)] if rest else text | |
| lab_word = re.match(r"\s*([A-Za-z][A-Za-z ]*[A-Za-z])", lab).group(1) | |
| suffix = lab[len(lab.split(lab_word, 1)[0]) + len(lab_word):] | |
| return {"label": lab_word, "suffix": suffix if suffix.strip() else (suffix or " "), "rest": rest} | |
| def caption_info(texts: List[str], kind: str) -> dict: | |
| out = {} | |
| for t in texts: | |
| m = re.match(r"^\s*(Fig\.?|Figure|FIGURE|FIG\.?|Table|TABLE|Tab\.?)\s*(\d+|[IVXLC]+)(\s*[.:—–\-|]?\s*)(.*)$", t) | |
| if not m: | |
| continue | |
| label, num, sep, rest = m.groups() | |
| if kind == "figure": | |
| out["figure_label"] = label[0] + label[1:].lower() if label.isupper() else label | |
| else: | |
| out["table_label"] = label.capitalize() if label.isupper() else label | |
| out["table_label_case"] = "upper" if label.isupper() else "as-is" | |
| out["table_numbering"] = "roman" if not num.isdigit() else "arabic" | |
| out["table_label_newline"] = not rest.strip() | |
| sep = re.sub(r"\s+", " ", sep) | |
| out["separator"] = sep if sep.strip() else " " | |
| if sep.strip() and not sep.endswith(" "): | |
| out["separator"] = sep + " " | |
| break | |
| return out | |
| # ------------------------------------------------------------------ DOCX template | |
| def analyze_docx_template(data: bytes): | |
| """Return (spec_overrides, TemplateProfile).""" | |
| import docx | |
| from .read_docx import DocxReader | |
| rdr = DocxReader(io.BytesIO(data)) | |
| doc = rdr.read() | |
| d = rdr.d | |
| styles = {s.get(qn("w:styleId")): s for s in d.styles.element.findall(qn("w:style"))} | |
| prof = TemplateProfile(data=data) | |
| ov: dict = {"page": {}, "font": {}, "title": {}, "abstract": {}, "keywords": {}, "headings": {}, | |
| "captions": {}, "references": {}, "citations": {}} | |
| # ---- sections | |
| body = d.element.body | |
| sect_list = [] | |
| for p in body.iter(qn("w:p")): | |
| ppr = p.find(qn("w:pPr")) | |
| if ppr is not None and ppr.find(qn("w:sectPr")) is not None: | |
| sect_list.append(ppr.find(qn("w:sectPr"))) | |
| final = body.find(qn("w:sectPr")) | |
| if final is not None: | |
| sect_list.append(final) | |
| prof.sections = [_x(s) for s in sect_list] | |
| def sect_info(s): | |
| info = {} | |
| pg = s.find(qn("w:pgSz")); mar = s.find(qn("w:pgMar")); cols = s.find(qn("w:cols")) | |
| if pg is not None: | |
| w, h = int(pg.get(qn("w:w"))), int(pg.get(qn("w:h"))) | |
| info["size"] = "A4" if abs(w - 11906) < 200 else ("Letter" if abs(w - 12240) < 200 else f"{w}x{h}") | |
| if mar is not None: | |
| g = lambda k: round(int(mar.get(qn(k), "1440")) * TWIP_CM, 2) | |
| info["margins_cm"] = [g("w:top"), g("w:bottom"), g("w:left"), g("w:right")] | |
| n = int(cols.get(qn("w:num"), "1")) if cols is not None else 1 | |
| info["columns"] = n | |
| if cols is not None and cols.get(qn("w:space")): | |
| info["col_gap_cm"] = round(int(cols.get(qn("w:space"))) * TWIP_CM, 2) | |
| return info | |
| if sect_list: | |
| ov["page"].update(sect_info(sect_list[-1])) | |
| if len(sect_list) > 1: | |
| first = sect_info(sect_list[0]) | |
| ov["page"]["front_matter_single_column"] = first.get("columns", 1) == 1 and ov["page"]["columns"] > 1 | |
| prof.front_section = _x(sect_list[0]) | |
| prof.found.append(f"page: {ov['page'].get('size')} · {ov['page'].get('columns')} column(s) · " | |
| f"margins {ov['page'].get('margins_cm')} cm") | |
| # ---- exemplars | |
| heading_texts = collections.defaultdict(list) | |
| ref_texts, para_texts = [], [] | |
| first_para_after_heading = None | |
| prev = None | |
| cap_seen = {"figure": [], "table": []} | |
| table_after_caption = None | |
| for b in doc.blocks: | |
| el = b.src | |
| if b.role == M.TABLE and el is not None: | |
| tp = el.find(qn("w:tblPr")) | |
| if prof.table_tblPr is None and tp is not None: | |
| prof.table_tblPr = _x(tp) | |
| if table_after_caption is None: | |
| table_after_caption = prev is not None and prev.role == M.CAPTION and prev.cap_kind == "table" | |
| prev = b | |
| continue | |
| if el is None or etree.QName(el).localname != "p": | |
| prev = b | |
| continue | |
| key = None | |
| if b.role == M.HEADING: | |
| key = f"h{min(b.level, 4)}" | |
| heading_texts[b.level].append(b.orig) | |
| elif b.role == M.CAPTION: | |
| key = f"caption_{b.cap_kind}" | |
| cap_seen[b.cap_kind].append(b.orig) | |
| elif b.role == M.LIST: | |
| key = "list_number" if b.ordered else "list_bullet" | |
| elif b.role == M.PARA: | |
| para_texts.append(b.orig) | |
| if len(b.orig) > 120 and "para" not in prof.exemplars: | |
| key = "para" | |
| elif "para_short" not in prof.exemplars: | |
| key = "para_short" | |
| if prev is not None and prev.role == M.HEADING and first_para_after_heading is None: | |
| first_para_after_heading = b | |
| prof.exemplars["para_first"] = None # placeholder filled below | |
| key2 = "para_first" | |
| dom, first = _dominant_rpr(el) | |
| prof.exemplars[key2] = {"pPr": sanitize_ppr(_x(el.find(qn("w:pPr")))), "rPr": sanitize_rpr(_x(dom)), | |
| "label_rPr": None, "auto_num": False} | |
| elif b.role == M.REFERENCE: | |
| ref_texts.append(b.orig) | |
| key = "reference" | |
| elif b.role in (M.TITLE, M.AUTHOR, M.AFFIL, M.ABSTRACT, M.KEYWORDS, M.EQUATION, M.FIGURE, | |
| M.ABSTRACT_HEADING, M.OTHER_FRONT): | |
| key = b.role | |
| if key and key not in prof.exemplars: | |
| labelled = b.role in (M.ABSTRACT, M.KEYWORDS, M.CAPTION) | |
| dom, first = _dominant_rpr(el, skip_label=labelled) | |
| prof.exemplars[key] = { | |
| "pPr": sanitize_ppr(_x(el.find(qn("w:pPr")))), | |
| "rPr": sanitize_rpr(_x(dom)), | |
| "label_rPr": sanitize_rpr(_x(first)) if labelled else None, | |
| "auto_num": _has_numpr(el, styles), | |
| "caps": _caps_in_style(el, dom, styles), | |
| "text": b.orig, | |
| } | |
| prev = b | |
| # ---- exemplars must not point at styles that hide text | |
| def _hidden_style(sid, depth=0): | |
| st = styles.get(sid) | |
| if st is None or depth > 8: | |
| return False | |
| rpr = st.find(qn("w:rPr")) | |
| if rpr is not None and rpr.find(qn("w:vanish")) is not None: | |
| return True | |
| b = st.find(qn("w:basedOn")) | |
| return _hidden_style(b.get(qn("w:val")), depth + 1) if b is not None else False | |
| for ex in prof.exemplars.values(): | |
| if ex and ex.get("pPr"): | |
| pe = etree.fromstring(ex["pPr"]) | |
| ps = pe.find(qn("w:pStyle")) | |
| if ps is not None and _hidden_style(ps.get(qn("w:val"))): | |
| pe.remove(ps) | |
| ex["pPr"] = etree.tostring(pe, encoding="unicode") | |
| # ---- derive spec overrides | |
| pe = prof.exemplars.get("para") or prof.exemplars.get("para_short") | |
| if pe and pe.get("rPr"): | |
| r = etree.fromstring(pe["rPr"]) | |
| fonts = r.find(qn("w:rFonts")) | |
| if fonts is not None and fonts.get(qn("w:ascii")): | |
| ov["font"]["family"] = fonts.get(qn("w:ascii")) | |
| sz = r.find(qn("w:sz")) | |
| if sz is not None: | |
| ov["font"]["size"] = int(sz.get(qn("w:val"))) / 2 | |
| if "family" not in ov["font"]: | |
| try: | |
| nf = d.styles["Normal"].font | |
| if nf.name: | |
| ov["font"]["family"] = nf.name | |
| except KeyError: | |
| pass | |
| if "size" not in ov["font"]: | |
| ov["font"]["size"] = rdr.default_size | |
| prof.found.append(f"body font: {ov['font'].get('family', '?')} {ov['font'].get('size')} pt") | |
| # headings | |
| hs = heading_scheme(heading_texts) | |
| for lvl in (1, 2, 3): | |
| ex = prof.exemplars.get(f"h{lvl}") | |
| if ex and ex.get("auto_num"): | |
| hs.setdefault("auto_levels", []).append(lvl) | |
| if hs: | |
| ov["headings"].update(hs) | |
| levels = [] | |
| for lvl in (1, 2, 3, 4): | |
| ts = heading_texts.get(lvl) | |
| ex = prof.exemplars.get(f"h{lvl}", {}) | |
| if ts: | |
| cases = [] | |
| for t in ts: | |
| bare = strip_heading_number(t)[1] | |
| c = text_case(bare) | |
| if len(bare.split()) == 1 and bare[:1].isupper() and c != "upper": | |
| c = "title-1" # one capitalised word: title or sentence case | |
| cases.append(c) | |
| multi = [c for c in cases if c not in ("title-1",)] | |
| case = collections.Counter(multi).most_common(1)[0][0] if multi else "title" | |
| if ex.get("caps"): | |
| case = "as-is" # style itself applies (small) caps | |
| levels.append({"case": case}) | |
| else: | |
| levels.append({}) | |
| ov["headings"]["levels"] = levels | |
| prof.found.append(f"headings: {hs.get('scheme')} numbering" + | |
| (" (Word auto-numbered)" if hs.get("auto_levels") else "")) | |
| # abstract / keywords labels | |
| ab = prof.exemplars.get(M.ABSTRACT) | |
| if M.ABSTRACT_HEADING in prof.exemplars: | |
| ov["abstract"].update(layout="block", heading=re.sub(r"[\s:—–.-]+$", "", prof.exemplars[M.ABSTRACT_HEADING]["text"]) or "Abstract") | |
| prof.found.append("abstract: separate heading line") | |
| elif ab and ab.get("text"): | |
| li = _label_info(ab["text"], ABSTRACT_RE) | |
| if li and li["rest"]: | |
| ov["abstract"].update(layout="inline", heading=li["label"], suffix=li["suffix"]) | |
| prof.found.append(f"abstract: inline label “{li['label']}{li['suffix'].strip() or ' '}”") | |
| kw = prof.exemplars.get(M.KEYWORDS) | |
| if kw and kw.get("text"): | |
| li = _label_info(kw["text"], KEYWORDS_RE) | |
| if li: | |
| rest = li["rest"] | |
| sep = "; " if rest.count(";") >= max(1, rest.count(",")) else (" · " if "·" in rest else ", ") | |
| if "—" in li["suffix"]: | |
| suffix = "—" | |
| else: | |
| suffix = li["suffix"] | |
| ov["keywords"].update(label=li["label"].strip(), suffix=suffix, separator=sep, | |
| end_period=rest.strip().endswith(".")) | |
| prof.found.append(f"keywords label “{li['label']}”, separator “{sep.strip()}”") | |
| # captions | |
| ci = caption_info(cap_seen["figure"], "figure") | |
| ci_t = caption_info(cap_seen["table"], "table") | |
| sep = ci.get("separator") or ci_t.get("separator") | |
| ov["captions"].update({k: v for k, v in {**ci_t, **ci}.items() if k != "separator"}) | |
| if sep: | |
| ov["captions"]["separator"] = sep | |
| if table_after_caption is not None: | |
| ov["captions"]["table_position"] = "above" if table_after_caption else "below" | |
| if ci or ci_t: | |
| prof.found.append(f"captions: “{ov['captions'].get('figure_label', 'Figure')} 1{ov['captions'].get('separator', '. ').rstrip()}”" | |
| f", tables “{ov['captions'].get('table_label', 'Table')} …” {ov['captions'].get('table_position', 'above')}") | |
| # references & citations | |
| rs = detect_ref_style(ref_texts) | |
| if rs: | |
| ov["references"]["style"] = rs | |
| prof.found.append(f"reference style: {rs}") | |
| cm = detect_citation_mode_text(" ".join(para_texts)) | |
| if cm: | |
| ov["citations"]["mode"] = cm | |
| elif rs: | |
| from .refs import NUMERIC_STYLES | |
| ov["citations"]["mode"] = "numeric" if rs in NUMERIC_STYLES else "author-year" | |
| for b in doc.blocks: | |
| if b.role == M.HEADING and re.sub(r"[^a-z]", "", b.text.lower()) in ("references", "bibliography", "literaturecited"): | |
| ov["references"]["heading"] = strip_heading_number(b.orig)[1] | |
| break | |
| # title case | |
| te = prof.exemplars.get(M.TITLE) | |
| ov = {k: v for k, v in ov.items() if v} | |
| return ov, prof | |
| # ------------------------------------------------------------------ PDF sample paper | |
| PDF_FONT_MAP = [ | |
| (r"times|nimbusrom|tex-?gyre-?termes|stix|liberationserif", "Times New Roman"), | |
| (r"palatino|pagella|urwpalladio", "Palatino Linotype"), | |
| (r"arial|helvetica|nimbussan|liberationsans", "Arial"), | |
| (r"calibri", "Calibri"), (r"cambria", "Cambria"), (r"georgia", "Georgia"), | |
| (r"cmr|lmroman|computermodern|sfrm", "Computer Modern"), (r"garamond", "Garamond"), | |
| (r"minion", "Minion Pro"), (r"charter", "Charter"), | |
| ] | |
| def map_pdf_font(name: str) -> str: | |
| n = re.sub(r"^[A-Z]{6}\+", "", name or "").lower().replace(" ", "") | |
| for pat, fam in PDF_FONT_MAP: | |
| if re.search(pat, n): | |
| return fam | |
| return re.sub(r"[-,].*$", "", re.sub(r"^[A-Z]{6}\+", "", name or "")) or "Times New Roman" | |
| def analyze_pdf_sample(data: bytes) -> dict: | |
| """Infer spec overrides from a published sample paper PDF.""" | |
| import pymupdf | |
| from .read_pdf import read_pdf | |
| pdf = pymupdf.open(stream=data, filetype="pdf") | |
| ov: dict = {"page": {}, "font": {}, "title": {}, "headings": {}, "abstract": {}, "keywords": {}, | |
| "captions": {}, "references": {}, "citations": {}, "_found": []} | |
| p0 = pdf[min(1, len(pdf) - 1)] | |
| w, h = p0.rect.width, p0.rect.height | |
| ov["page"]["size"] = "A4" if abs(w - 595) < 8 else ("Letter" if abs(w - 612) < 8 else f"{w:.0f}x{h:.0f}pt") | |
| xs0, xs1, ys0, ys1 = [], [], [], [] | |
| bottoms = [] | |
| left_n = right_n = full_n = 0 | |
| for pg in list(pdf)[1: min(len(pdf), 5)] or list(pdf)[:1]: | |
| for b in pg.get_text("blocks"): | |
| x0, y0, x1, y1, t = b[:5] | |
| if len(t.strip()) < 40: | |
| continue | |
| xs0.append(x0); xs1.append(x1); ys0.append(y0); ys1.append(y1) | |
| if x1 < pg.rect.width / 2 + 10: | |
| left_n += 1 | |
| elif x0 > pg.rect.width / 2 - 10: | |
| right_n += 1 | |
| else: | |
| full_n += 1 | |
| if len(pdf) > 1: | |
| for pg in list(pdf)[:-1]: | |
| ys = [b[3] for b in pg.get_text("blocks") if len(b[4].strip()) > 40 and b[3] < pg.rect.height * 0.93] | |
| if ys: | |
| bottoms.append(max(ys)) | |
| if xs0: | |
| pt_cm = 2.54 / 72 | |
| bottom = max(bottoms) if bottoms else max(ys1) | |
| ov["page"]["margins_cm"] = [round(min(ys0) * pt_cm, 2), round((h - bottom) * pt_cm, 2), | |
| round(min(xs0) * pt_cm, 2), round((w - max(xs1)) * pt_cm, 2)] | |
| ov["page"]["columns"] = 2 if left_n >= 3 and right_n >= 3 and (left_n + right_n) > full_n else 1 | |
| # fonts | |
| fonts = collections.Counter(); sizes = collections.Counter() | |
| for pg in pdf: | |
| for b in pg.get_text("dict")["blocks"]: | |
| for l in b.get("lines", []): | |
| for s in l["spans"]: | |
| fonts[s["font"]] += len(s["text"]); sizes[round(s["size"] * 2) / 2] += len(s["text"]) | |
| if fonts: | |
| ov["font"]["family"] = map_pdf_font(fonts.most_common(1)[0][0]) | |
| ov["font"]["size"] = sizes.most_common(1)[0][0] | |
| ov["_found"].append(f"page: {ov['page']['size']} · {ov['page']['columns']} column(s)") | |
| ov["_found"].append(f"body font: {ov['font'].get('family')} {ov['font'].get('size')} pt") | |
| doc = read_pdf(data) | |
| t = doc.first(M.TITLE) | |
| if t: | |
| ov["title"]["size"] = round(t.src_size) | |
| ov["title"]["bold"] = t.src_bold | |
| htexts = collections.defaultdict(list) | |
| hsizes = collections.defaultdict(list) | |
| for b in doc.blocks: | |
| if b.role == M.HEADING: | |
| htexts[b.level].append(b.orig) | |
| hsizes[b.level].append((b.src_size, b.src_bold)) | |
| hs = heading_scheme(htexts) | |
| if hs: | |
| ov["headings"].update(hs) | |
| levels = [] | |
| for lvl in (1, 2, 3, 4): | |
| if htexts.get(lvl): | |
| bare = strip_heading_number(htexts[lvl][0])[1] | |
| sz, bold = hsizes[lvl][0] | |
| levels.append({"case": text_case(bare) if text_case(bare) == "upper" else "as-is", | |
| "size": round(sz * 2) / 2, "bold": bold}) | |
| else: | |
| levels.append({}) | |
| ov["headings"]["levels"] = levels | |
| ov["_found"].append(f"headings: {hs.get('scheme')} numbering") | |
| if doc.first(M.ABSTRACT_HEADING): | |
| ov["abstract"]["layout"] = "block" | |
| else: | |
| ab = doc.first(M.ABSTRACT) | |
| if ab: | |
| li = _label_info(ab.orig, ABSTRACT_RE) | |
| if li and li["rest"]: | |
| ov["abstract"].update(layout="inline", heading=li["label"], suffix=li["suffix"]) | |
| kw = doc.first(M.KEYWORDS) | |
| if kw: | |
| li = _label_info(kw.orig, KEYWORDS_RE) | |
| if li: | |
| rest = li["rest"] | |
| sep = "; " if rest.count(";") >= max(1, rest.count(",")) else (" · " if "·" in rest else ", ") | |
| ov["keywords"].update(label=li["label"].strip(), suffix="—" if "—" in li["suffix"] else li["suffix"], | |
| separator=sep) | |
| figs = [b.orig for b in doc.blocks if b.role == M.CAPTION and b.cap_kind == "figure"] | |
| tabs = [b.orig for b in doc.blocks if b.role == M.CAPTION and b.cap_kind == "table"] | |
| ci = {**caption_info(tabs, "table"), **caption_info(figs, "figure")} | |
| ov["captions"].update(ci) | |
| rs = detect_ref_style([b.orig for b in doc.blocks if b.role == M.REFERENCE]) | |
| if rs: | |
| ov["references"]["style"] = rs | |
| ov["_found"].append(f"reference style: {rs}") | |
| cm = detect_citation_mode_text(" ".join(b.orig for b in doc.blocks if b.role == M.PARA)) | |
| if cm: | |
| ov["citations"]["mode"] = cm | |
| found = ov.pop("_found") | |
| ov = {k: v for k, v in ov.items() if v} | |
| ov["_found"] = found | |
| return ov | |