diff --git a/docs/e2sm-dapp/05-semantics-and-constraints.md b/docs/e2sm-dapp/05-semantics-and-constraints.md index f7d0736a..d2802fec 100644 --- a/docs/e2sm-dapp/05-semantics-and-constraints.md +++ b/docs/e2sm-dapp/05-semantics-and-constraints.md @@ -43,7 +43,7 @@ Table: Identifiers and their ranges | `dapp-id` | the dApp | `0..4294967295` | `1..65535` | | | `SubscribedE3RANFunction-ID` | an E3 RAN function a dApp has subscribed to | `0..4294967295` | `1..65535` | | | `sequence-id` | one detection-to-mitigation procedure | unbounded, optional | `1..4294967295` (`E3-SequenceID`) | **`E3-SequenceID` equals E2SM-DAPP `sequence-id`**: same value, assigned by the dApp, never reassigned. | -| `node-type`, `node-plmn-id`, `node-nb-id`, `node-cu-du-id` | identity of the reporting node | see 02 | | the grammar defines no enumeration and no packing. See 02. | +| `node-type`, `node-plmn-id`, `node-nb-id`, `node-cu-du-id` | identity of the reporting node | see [02](02-ie-mapping.md) | | the grammar defines no enumeration and no packing. See [02](02-ie-mapping.md). | **A sender should keep the E2SM-DAPP ids inside the E3 ranges.** E2SM-DAPP can carry `0` and values above `65535`. A RAN that relays a control over E3 cannot deliver them, diff --git a/docs/e2sm-dapp/O-RAN.XXX.TS.E2SM-DAPP-R000-v00.01.docx b/docs/e2sm-dapp/O-RAN.XXX.TS.E2SM-DAPP-R000-v00.01.docx index 52f32bfe..be5f3a9e 100644 Binary files a/docs/e2sm-dapp/O-RAN.XXX.TS.E2SM-DAPP-R000-v00.01.docx and b/docs/e2sm-dapp/O-RAN.XXX.TS.E2SM-DAPP-R000-v00.01.docx differ diff --git a/docs/e2sm-dapp/README.md b/docs/e2sm-dapp/README.md index 62d6ad7e..0689bdc9 100644 --- a/docs/e2sm-dapp/README.md +++ b/docs/e2sm-dapp/README.md @@ -26,6 +26,9 @@ The present document defines the RAN function definition, the information elemen the procedures of this service model. It also defines the APER encoding of the information elements and the handling of unknown and erroneous data. +This document is generated from the codebase and protocol definitions for the libe3 +implementation in https://github.com/wineslab/libe3. + ## References ### Normative references @@ -37,7 +40,7 @@ applies. For a non-specific reference, the latest version applies. The following referenced documents are necessary for the application of the present document. -1. O-RAN-WG3.TS.E2GAP: "O-RAN E2 General Aspects and Principles". +1. O-RAN.WG3.TS.E2GAP: "O-RAN E2 General Aspects and Principles". 2. O-RAN.WG3.TS.E2AP: "O-RAN E2 Application Protocol (E2AP)". 3. O-RAN.WG3.TS.E2SM: "O-RAN E2 Service Model (E2SM)". 4. ITU-T Recommendation X.680: "Information technology: Abstract Syntax Notation One (ASN.1): Specification of basic notation". @@ -49,15 +52,15 @@ document. The following referenced documents are not necessary for the application of the present document, but they help the reader. -1. The E3 Application Protocol (E3AP) specification, [../e3ap/README.md](../e3ap/README.md). +1. O-RAN.XXX.TS.E3AP-R000-v00.01: "E3 Application Protocol (E3AP)", https://github.com/wineslab/libe3/tree/main/docs/e3ap. 2. The OpenAirInterface implementation of E3AP and of this service model: https://github.com/duranta-project/openairinterface5g/pull/729. ## Terms, symbols and abbreviations ### Terms -For the purposes of the present document, the terms and definitions given in O-RAN -WG3.TS.E2GAP [1], O-RAN.WG3.TS.E2SM [3] and the following apply. +For the purposes of the present document, the terms and definitions given in +O-RAN.WG3.TS.E2GAP [1], O-RAN.WG3.TS.E2SM [3] and the following apply. * **dApp**: an application that runs next to the E2 node and talks to it over E3. * **dApp E3 subscription map**: the list of the dApps connected to the E2 node, each with the E3 RAN functions it has subscribed to. diff --git a/docs/e2sm-dapp/docx.json b/docs/e2sm-dapp/docx.json index 5c27167a..a4caa068 100644 --- a/docs/e2sm-dapp/docx.json +++ b/docs/e2sm-dapp/docx.json @@ -2,7 +2,6 @@ "number": "O-RAN.XXX.TS.E2SM-DAPP-R000-v00.01", "title": "E2 Service Model dApp (E2SM-DAPP)", "cover": ["E2 Service Model (E2SM)", "dApp (E2SM-DAPP)"], - "group": {"cover": ["O-RAN Work Group 3", "Near-Real-time RAN Intelligent Controller"], "foreword": "WG3"}, "parts": [ {"clause": "1", "title": "Scope", "file": "README.md", "section": "Scope"}, {"clause": "2", "title": "References", "file": "README.md", "section": "References"}, diff --git a/docs/e3ap/O-RAN.XXX.TS.E3AP-R000-v00.01.docx b/docs/e3ap/O-RAN.XXX.TS.E3AP-R000-v00.01.docx index 94dc7da3..bd15b156 100644 Binary files a/docs/e3ap/O-RAN.XXX.TS.E3AP-R000-v00.01.docx and b/docs/e3ap/O-RAN.XXX.TS.E3AP-R000-v00.01.docx differ diff --git a/docs/e3ap/README.md b/docs/e3ap/README.md index 692ee2c9..64d485ad 100644 --- a/docs/e3ap/README.md +++ b/docs/e3ap/README.md @@ -19,6 +19,9 @@ how a RAN node produces telemetry or applies a control action. It does not defin the E2 interface. The E2SM-DAPP specification defines how a RIC reaches a dApp through the E2 interface. See the [E2SM-DAPP specification](../e2sm-dapp/README.md). +This document is generated from the codebase and protocol definitions for the libe3 +implementation in https://github.com/wineslab/libe3. + ## References ### Normative references @@ -43,9 +46,9 @@ References are specific. Only the cited version applies. [i.1] Protocol Buffers Language Guide (proto3), https://protobuf.dev/programming-guides/proto3/. -[i.2] The E2SM-DAPP specification of this series. See [../e2sm-dapp/README.md](../e2sm-dapp/README.md). +[i.2] O-RAN.XXX.TS.E2SM-DAPP-R000-v00.01: "E2 Service Model dApp (E2SM-DAPP)", https://github.com/wineslab/libe3/tree/main/docs/e2sm-dapp. -[i.3] The E3SM Spectrum and E3SM L1-KPM specifications of this series. See [../e3sm/README.md](../e3sm/README.md). +[i.3] O-RAN.XXX.TS.E3SM-SPEC-R000-v00.01: "E3 Service Model Spectrum (E3SM-SPEC)", https://github.com/wineslab/libe3/tree/main/docs/e3sm/spectrum, and O-RAN.XXX.TS.E3SM-L1-KPM-R000-v00.01: "E3 Service Model (E3SM), L1 KPM (E3SM-L1-KPM)", https://github.com/wineslab/libe3/tree/main/docs/e3sm/l1-kpm. [i.4] The OpenAirInterface implementation of E3AP, https://github.com/duranta-project/openairinterface5g/pull/729. diff --git a/docs/e3sm/l1-kpm/O-RAN.XXX.TS.E3SM-L1-KPM-R000-v00.01.docx b/docs/e3sm/l1-kpm/O-RAN.XXX.TS.E3SM-L1-KPM-R000-v00.01.docx index 69e2435a..8a81cd72 100644 Binary files a/docs/e3sm/l1-kpm/O-RAN.XXX.TS.E3SM-L1-KPM-R000-v00.01.docx and b/docs/e3sm/l1-kpm/O-RAN.XXX.TS.E3SM-L1-KPM-R000-v00.01.docx differ diff --git a/docs/e3sm/l1-kpm/README.md b/docs/e3sm/l1-kpm/README.md index bfafe09f..bf803602 100644 --- a/docs/e3sm/l1-kpm/README.md +++ b/docs/e3sm/l1-kpm/README.md @@ -44,6 +44,9 @@ The present document does not define E3AP messages or procedures. They are defin [1]. It does not define how a RAN node computes the values that it reports. It does not define the transport between the dApp and the E3 Agent. +This document is generated from the codebase and protocol definitions for the libe3 +implementation in https://github.com/wineslab/libe3. + ## References ### Normative references @@ -53,7 +56,7 @@ References are specific. Only the cited version applies. The following referenced documents are necessary for the application of the present document. -[1] "[E3 Application Protocol (E3AP)](../../e3ap/README.md)", R000 v00.01. +[1] O-RAN.XXX.TS.E3AP-R000-v00.01: "E3 Application Protocol (E3AP)", https://github.com/wineslab/libe3/tree/main/docs/e3ap. [2] ITU-T Recommendation X.680 (2002-07): "Information technology: Abstract Syntax Notation One (ASN.1): Specification of basic notation". diff --git a/docs/e3sm/spectrum/O-RAN.XXX.TS.E3SM-SPEC-R000-v00.01.docx b/docs/e3sm/spectrum/O-RAN.XXX.TS.E3SM-SPEC-R000-v00.01.docx index 9983ed89..7d6c87f8 100644 Binary files a/docs/e3sm/spectrum/O-RAN.XXX.TS.E3SM-SPEC-R000-v00.01.docx and b/docs/e3sm/spectrum/O-RAN.XXX.TS.E3SM-SPEC-R000-v00.01.docx differ diff --git a/docs/e3sm/spectrum/README.md b/docs/e3sm/spectrum/README.md index b6bfb4c1..941c1a33 100644 --- a/docs/e3sm/spectrum/README.md +++ b/docs/e3sm/spectrum/README.md @@ -33,6 +33,9 @@ The document does not define the E3 Application Protocol. It does not define the E2 service model that carries dApp traffic to an xApp. It does not define how a scheduler enforces a PRB block internally. +This document is generated from the codebase and protocol definitions for the libe3 +implementation in https://github.com/wineslab/libe3. + ## References ### Normative references @@ -43,12 +46,12 @@ applies. For non-specific references, the latest version of the referenced docum applies. > NOTE: While any hyperlinks included in this clause were valid at the time of -> publication, O-RAN cannot guarantee their long-term validity. +> publication, their long-term validity cannot be guaranteed. The following referenced documents are necessary for the application of the present document. -[1] E3AP specification: "E3 Application Protocol (E3AP)", see [the E3AP specification](../../e3ap/README.md). +[1] O-RAN.XXX.TS.E3AP-R000-v00.01: "E3 Application Protocol (E3AP)", https://github.com/wineslab/libe3/tree/main/docs/e3ap. [2] ITU-T Recommendation X.680 (2002-07): "Information technology: Abstract Syntax Notation One (ASN.1): Specification of basic notation". diff --git a/tools/oran_docx/README.md b/tools/oran_docx/README.md index b08ffd92..5b830780 100644 --- a/tools/oran_docx/README.md +++ b/tools/oran_docx/README.md @@ -1,7 +1,13 @@ # oran_docx -Builds the O-RAN Word edition of a specification from its Markdown folder. The -specifications live under `docs/` (`e2sm-dapp/`, `e3ap/`, `e3sm/spectrum/`, +Builds an O-RAN-like Word edition of a specification from its Markdown folder. +The document follows the layout of an O-RAN Technical Specification: the cover, +the copyright page, the contents, the Foreword, the Modal verbs clause, the +clause numbering, the styles, the headers and the footers. It is not an O-RAN +ALLIANCE document. It carries no O-RAN logo and no working group, and its +copyright belongs to Northeastern University under the Apache License 2.0. + +The specifications live under `docs/` (`e2sm-dapp/`, `e3ap/`, `e3sm/spectrum/`, `e3sm/l1-kpm/`). Each folder carries the Markdown chapters, a manifest `docx.json` and the generated `O-RAN.XXX.TS.*.docx`. @@ -26,8 +32,8 @@ docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/src libe3-oran-docx docs/e3ap |---|---| | `build_docx.py` | the generator | | `Dockerfile` | Ubuntu 24.04 with Python 3, LibreOffice, `pdftotext` and the Liberation fonts | -| `template.docx` | the O-RAN template, cut down to the cover, the copyright page, the Foreword, the Modal verbs clause, the styles, the headers and the footers. The cover and the Foreword name O-RAN Work Group X, Real-Time Distributed Applications (dApps) | -| `make_template.py` | how `template.docx` was cut from an O-RAN specification and given the working group of the E3 specifications; run it again only when the O-RAN template changes | +| `template.docx` | the O-RAN template, cut down to the cover, the copyright page, the Foreword, the Modal verbs clause, the styles, the headers and the footers. It has no O-RAN logo, no working group and no rule across the cover; the copyright page and the Foreword name Northeastern University and the Apache License 2.0, and the footers carry only the page number | +| `make_template.py` | how `template.docx` was made from an O-RAN specification: it keeps the layout and writes the front matter of the libe3 specifications. Run it again only when the O-RAN template or the front matter changes | ## The manifest @@ -47,13 +53,7 @@ docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/src libe3-oran-docx docs/e3ap * `number` is the document number. It goes on the cover, in the headers and in the document properties. -* `cover` holds the two title lines under the working group. -* `group` is optional. The template names O-RAN Work Group X, Real-Time Distributed - Applications (dApps), where `X` holds the place of the number until the naming is - agreed. `group` names another working group: `cover` gives its two lines on the - cover and `foreword` its short name in the Foreword. E2SM-DAPP is an E2 service - model and sets `{"cover": ["O-RAN Work Group 3", "Near-Real-time RAN Intelligent - Controller"], "foreword": "WG3"}`. +* `cover` holds the two title lines of the cover. * A part is a whole Markdown file, or with `section` the text under one level 2 heading of the file. Its `clause` gives its place and heading level: `5` is a level 1 heading, `8.2` a level 2 heading, `A` an annex. @@ -65,8 +65,8 @@ docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/src libe3-oran-docx docs/e3ap Headings, paragraphs, bullet and numbered lists (two levels), pipe tables, fenced code blocks, block quotes (shown as notes), and the inline marks for code, bold, -italic and links. A link shows its text; an `http` link also shows its address. -A line that holds only HTML anchors, such as ``, is left out. +italic and links. A line that holds only HTML anchors, such as +``, is left out, and its anchors name the heading below it. A fenced code block is set in the PL style of the template, in one box with a light gray background and a border. @@ -86,6 +86,27 @@ size that its resolution gives and at most the width of the text, with the capti each clause, as tables are, so the text can refer to "Figure 5-1". PNG and JPEG files are accepted. A missing file stops the build. +## Links + +The Word edition has no files to link to, so each link becomes a reference that a +reader of the document can follow: + +| Link in the Markdown | In Word | +|---|---| +| `[04-encoding.md](04-encoding.md)`, to a file of a part | `clause 8` | +| `[encoding](04-encoding.md#json)`, with an anchor of a heading | `encoding (clause 8.3)` | +| `[06](06-discrepancies.md)`, `[section 2](#formats)` | `Annex A`, `clause 6.2` | +| `[E2SM-DAPP](../e2sm-dapp/README.md)`, to any other file | `E2SM-DAPP (https://github.com/wineslab/libe3/tree/main/docs/e2sm-dapp)` | +| `[../interop.md](../interop.md)` | `https://github.com/wineslab/libe3/blob/main/docs/interop.md` | +| `[RFC 9293](https://...)` | `RFC 9293 (https://...)` | + +A label that only names the target (its path, its file name, its number such as +`06`, or a clause number of the Markdown edition) is replaced; any other label is +kept and the reference follows it in parentheses. An anchor is the one GitHub gives +the heading, or an `` above it. An anchor that matches no heading falls back +to the clause of the whole file and prints a note. The sentence "The index is +README.md." is left out, since the Word edition has its own contents. + ## Check the result ```bash diff --git a/tools/oran_docx/build_docx.py b/tools/oran_docx/build_docx.py index 3cfbb66d..f900da2d 100644 --- a/tools/oran_docx/build_docx.py +++ b/tools/oran_docx/build_docx.py @@ -29,16 +29,14 @@ ![caption](figures/flow.png), becomes a figure with that caption. Fenced blocks become a box of monospaced lines. -The styles, headers, footers and cover come from template.docx, which -make_template.py cuts from an O-RAN specification. The template names the -working group of DEFAULT_GROUP on the cover and in the Foreword. A manifest -names another one with "group", as E2SM-DAPP does: - - "group": {"cover": ["O-RAN Work Group 3", "Near-Real-time RAN Intelligent Controller"], - "foreword": "WG3"} +A link to a file of the same specification becomes its clause number +("clause 8", "Annex A", or with an anchor "clause 8.2"); every other link +becomes an absolute GitHub URL. The sentence "The index is README.md." is +left out. -The page numbers of the -table of contents come from a LibreOffice layout, so Word may move them by a +The styles, headers, footers and cover come from template.docx, which +make_template.py cuts from an O-RAN specification and gives the copyright of +Northeastern University. The page numbers of the table of contents come from a LibreOffice layout, so Word may move them by a page: press F9 on the table of contents in Word to refresh them. """ import argparse @@ -54,12 +52,12 @@ from xml.sax.saxutils import escape HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) +# Links to files outside the specification point here, on the main branch. +REPO_URL = "https://github.com/wineslab/libe3" TOC_MARK = "@@TOC@@" BODY_MARK = "@@BODY@@" TEMPLATE_NUMBER = "O-RAN.WG3.TS.E2SM-KPM-R005-v08.00" -# The working group of the E3 specifications. X holds the place of its number -# until the naming is agreed, as XXX does in the document numbers. -DEFAULT_GROUP = {"cover": ["O-RAN Work Group X", "Real-Time Distributed Applications (dApps)"], "foreword": "WGX"} TABLE_WIDTH = 9631 MONO = '' CODE_BOX = ( @@ -119,9 +117,12 @@ def inline_runs(text, **base): out.append(run(unescape_md(m.group("ital")), **{**base, "italic": True})) else: label, url = m.group("ltext"), m.group("lurl") - out.append("".join(inline_runs(label, **base))) - if url.startswith("http"): - out.append(run(f" ({url})", **base)) + if url.startswith("http") or LINKS is None: + out.append("".join(inline_runs(label, **base))) + if url.startswith("http"): + out.append(run(f" ({url})", **base)) + else: + out.append(LINKS.runs(label, url, base)) pos = m.end() if pos < len(text): out.append(run(unescape_md(text[pos:]), **base)) @@ -139,6 +140,91 @@ def plain(text): return unescape_md(t) +# ---------------------------------------------------------------------- links + + +def slug(heading): + """The anchor that GitHub gives a heading.""" + return re.sub(r"[^\w\- ]", "", plain(heading).strip().lower()).replace(" ", "-") + + +class Links: + """Links of the Markdown edition, made into references that a reader of the Word edition can follow. + + A link to a file of the specification becomes the clause that holds its target. The + clause numbers are known only when every part is rendered, so a link leaves a marker + in the text, and resolve() puts the number in. Any other link becomes a GitHub URL. + """ + + def __init__(self, folder, parts): + self.folder = os.path.abspath(folder) + self.files = {os.path.join(self.folder, p["file"]) for p in parts if "file" in p} + self.whole = {} # file -> clause of the part that is the whole file + self.anchors = {} # (file, anchor) -> clause + self.seen = {} # (file, slug) -> times seen, for GitHub's -1, -2 suffixes + self.refs = [] # (file, anchor, url to fall back to) + self.current = None + + def enter(self, part, clause): + self.current = os.path.join(self.folder, part["file"]) + if part.get("section"): + self.add(self.current, slug(part["section"]), clause) + else: + self.whole[self.current] = clause + + def add(self, path, anchor, clause): + n = self.seen.get((path, anchor), 0) + self.seen[(path, anchor)] = n + 1 + self.anchors.setdefault((path, anchor if n == 0 else f"{anchor}-{n}"), clause) + + def heading(self, text, ids, clause): + self.add(self.current, slug(text), clause) + for i in ids: + self.anchors.setdefault((self.current, i), clause) + + def url(self, path, anchor): + rel = os.path.relpath(path, REPO_ROOT).replace(os.sep, "/") + if os.path.basename(path) == "README.md" and not anchor: + return f"{REPO_URL}/tree/main/{os.path.dirname(rel)}" + return f"{REPO_URL}/blob/main/{rel}" + (f"#{anchor}" if anchor else "") + + def runs(self, label, url, base): + path, _, anchor = url.partition("#") + target = os.path.normpath(os.path.join(os.path.dirname(self.current), path)) if path else self.current + text = plain(label).strip() + # A label that only names the target: the path, the file name, its number + # ("06" for 06-discrepancies.md) or a clause number of the Markdown edition. + bare = ( + text in (url, path, os.path.basename(path)) + or (re.fullmatch(r"\d\d", text) and os.path.basename(path).startswith(text + "-")) + or re.fullmatch(r"(?i)(clause|section|annex)\s+[\w.]+", text) + ) + if target in self.files: + self.refs.append((target, anchor, self.url(target, anchor))) + ref = f"@@REF{len(self.refs) - 1}@@" + else: + ref = self.url(target, anchor) + if bare: + return run(ref, **base) + return "".join(inline_runs(label, **base)) + run(f" ({ref})", **base) + + def resolve(self, xml): + def number(m): + target, anchor, fallback = self.refs[int(m.group(1))] + clause = self.anchors.get((target, anchor)) + if clause is None: + if anchor: + print(f"note: no heading for #{anchor} in {os.path.relpath(target, REPO_ROOT)}", file=sys.stderr) + clause = self.whole.get(target) + if clause is None: + return fallback + return f"Annex {clause}" if clause[0].isalpha() and "." not in clause else f"clause {clause}" + return re.sub(r"@@REF(\d+)@@", number, xml) + + +LINKS = None # the Links of the specification being built + + # ------------------------------------------------------------- markdown blocks @@ -160,8 +246,9 @@ def longest_word(text): # A line that holds only HTML anchors, such as , is a link target -# for the Markdown edition. Word has its own bookmarks, so the line is dropped. +# for the Markdown edition. The line is dropped and its ids go to the next heading. ANCHOR = re.compile(r'^\s*(\s*\s*)+$') +ANCHOR_ID = re.compile(r'(?:id|name)="([^"]*)"') # An image on a line of its own is a figure. Its alt text is the caption. IMAGE = re.compile(r'^\s*!\[(?P[^\]]*)\]\((?P[^)\s]+)(?:\s+"[^"]*")?\)\s*$') @@ -169,6 +256,7 @@ def longest_word(text): def parse_blocks(lines): """Split Markdown lines into blocks: heading, para, list, table, code, quote.""" blocks = [] + ids = [] # anchor ids waiting for the next heading i = 0 n = len(lines) while i < n: @@ -182,6 +270,7 @@ def parse_blocks(lines): i += 1 continue if ANCHOR.match(line): + ids += ANCHOR_ID.findall(line) i += 1 continue m = IMAGE.match(line) @@ -202,7 +291,8 @@ def parse_blocks(lines): continue m = re.match(r"^(#{1,6})\s+(.*?)\s*#*\s*$", line) if m: - blocks.append(("heading", len(m.group(1)), m.group(2))) + blocks.append(("heading", len(m.group(1)), m.group(2), ids)) + ids = [] i += 1 continue if re.match(r"^\s*(-{3,}|\*{3,}|_{3,})\s*$", line): @@ -477,6 +567,7 @@ def render_part(doc, part, base): top = clause.split(".")[0] if "file" not in part: return + LINKS.enter(part, clause) with open(os.path.join(base, part["file"]), encoding="utf8") as f: blocks = parse_blocks(f.read().split("\n")) blocks, top_level = select(blocks, part.get("section")) @@ -494,11 +585,15 @@ def render_part(doc, part, base): number = f"{parent}.{counts[parent]}" path.append(number) doc.heading(depth + rel, number, text) + LINKS.heading(b[2], b[3], number) elif kind == "para": if b[1].startswith("Table:") and i + 1 < len(blocks) and blocks[i + 1][0] == "table": caption = b[1][len("Table:"):].strip() else: - doc.para(inline_runs(b[1])) + # The Word edition has its own contents, so it needs no pointer to the index. + text = re.sub(r"\s*The index is \[README\.md\]\([^)]*\)\.", "", b[1]).strip() + if text: + doc.para(inline_runs(text)) elif kind == "list": doc.lst(b[1]) elif kind == "table": @@ -558,7 +653,6 @@ def toc_xml(doc, pages, extra): def set_cover(xml, number, cover): za = re.search(r"]*>(?:(?!).)*?w:val=\"ZA\".*?", xml, re.S) p = za.group(0) - drawing = re.search(r"](?:(?!).)*?.*?", p, re.S).group(0) sep = p.index('w:fldCharType="separate"') head = p[: p.rfind("' + run(number) + '' - + drawing + "" ) xml = xml.replace(p, newp, 1) @@ -579,33 +672,6 @@ def set_cover(xml, number, cover): return xml -def set_group(xml, group): - """Name the working group on the cover (two lines) and in the Foreword.""" - def text(p): - return "".join(re.findall(r"]*>([^<]*)", p)) - - def rewrite(p, runs): - ppr = re.search(r".*?", p, re.S) - return re.match(r"]*>", p).group(0) + (ppr.group(0) if ppr else "") + runs + "" - - foreword = f"This Technical Specification (TS) has been produced by {group['foreword']} of the O-RAN ALLIANCE." - found = [] - - def fix(m): - p = m.group(0) - if 'w:val="ZT"' in p and text(p).startswith("O-RAN Work Group"): - found.append("cover") - return rewrite(p, "".join(run(t) for t in group["cover"])) - if text(p).startswith("This Technical Specification (TS) has been produced by "): - found.append("foreword") - return rewrite(p, run(foreword)) - return p - - xml = re.sub(r"](?:(?!).)*", fix, xml, flags=re.S) - assert sorted(found) == ["cover", "foreword"], "template cover or Foreword changed" - return xml - - def history_table(doc, rows): doc.heading(1, "", "Change history", annex_label="Annex (informative):") doc.table(["Date", "Revision", "Description", "Author"], rows, None, "H") @@ -670,11 +736,14 @@ def pages_from_pdf(docx_path, headings, extra): def build(folder, out, with_pages=True): with open(os.path.join(folder, "docx.json"), encoding="utf8") as f: man = json.load(f) + global LINKS + LINKS = Links(folder, man["parts"]) doc = Doc() for part in man["parts"]: render_part(doc, part, folder) if man.get("history"): history_table(doc, man["history"]) + doc.xml = [LINKS.resolve(x) for x in doc.xml] extra = [("Foreword", "_Toc223965221"), ("Modal verbs terminology", "_Toc223965222")] tpl = zipfile.ZipFile(os.path.join(HERE, "template.docx")) @@ -687,8 +756,6 @@ def write(path, pages): if name == "word/document.xml": x = data.decode("utf8") x = set_cover(x, man["number"], man["cover"]) - if man.get("group"): - x = set_group(x, man["group"]) x = x.replace(TOC_MARK, toc_xml(doc, pages, extra)).replace(BODY_MARK, "".join(doc.xml)) data = x.encode("utf8") elif name == "word/_rels/document.xml.rels": @@ -704,6 +771,8 @@ def write(path, pages): data = x.encode("utf8") elif name in ("word/header1.xml", "word/header2.xml"): data = data.decode("utf8").replace(TEMPLATE_NUMBER, man["number"]).encode("utf8") + elif name == "docProps/app.xml": + data = re.sub(r"(.*?).*?()", lambda m: m.group(1) + escape(man["number"]) + m.group(2), data.decode("utf8"), flags=re.S).encode("utf8") elif name == "docProps/core.xml": data = re.sub(r".*?", f"{escape(man['number'])}", data.decode("utf8")).encode("utf8") elif name == "docProps/custom.xml": diff --git a/tools/oran_docx/make_template.py b/tools/oran_docx/make_template.py index bd5c0919..c6386683 100644 --- a/tools/oran_docx/make_template.py +++ b/tools/oran_docx/make_template.py @@ -1,27 +1,30 @@ #!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2026 Northeastern University # SPDX-License-Identifier: Apache-2.0 -"""Cut an O-RAN specification down to the template that build_docx.py fills. +"""Make the template that build_docx.py fills from an O-RAN specification. -The O-RAN template ships as a full specification. This script keeps what every -specification has: the cover, the copyright page, the "Contents" heading, the -Foreword and the Modal verbs clause, the styles, the numbering, the headers -and the footers. It replaces the table of contents and the body with two -markers, and ends the document with one section, so a generated document needs -no landscape or per-clause section breaks. The cover and the Foreword name the -working group of the E3 specifications, DEFAULT_GROUP of build_docx.py, instead -of O-RAN Work Group 3. +The O-RAN template ships as a full specification. This script keeps its layout: +the cover, the copyright page, the "Contents" heading, the Foreword and the Modal +verbs clause, the styles, the numbering, the headers and the footers. It replaces +the table of contents and the body with two markers, and ends the document with +one section, so a generated document needs no landscape or per-clause section +breaks. + +The front matter is that of the libe3 specifications. The cover has the document +number and the title and no logo. The copyright page names Northeastern +University and the Apache License 2.0. The Foreword says who produced the +document, and the Modal verbs clause refers to the ETSI Drafting Rules. The +headers carry the document number and the footers the page number. make_template.py O-RAN.WG3.TS.E2SM-KPM-R005-v08.00.docx template.docx -Run it again only when the O-RAN template changes. The result is committed as -template.docx next to this script. +Run it again only when the O-RAN template or the front matter changes. The +result is committed as template.docx next to this script. """ import re import sys import zipfile - -from build_docx import DEFAULT_GROUP, set_group +from xml.sax.saxutils import escape TOC_MARK = "@@TOC@@" BODY_MARK = "@@BODY@@" @@ -39,6 +42,44 @@ '' ) +OWNER = "Northeastern University" +# The paragraphs of the copyright page, in order. +COPYRIGHT = [ + f"Copyright © 2026 {OWNER}", + 'Licensed under the Apache License, Version 2.0 (the "License"); you may not use this ' + "document except in compliance with the License. You may obtain a copy of the License at " + "http://www.apache.org/licenses/LICENSE-2.0. Unless required by applicable law or agreed to " + 'in writing, material distributed under the License is distributed on an "AS IS" BASIS, ' + "WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for " + "the specific language governing permissions and limitations under the License.", + "", + f"{OWNER}, 360 Huntington Avenue, Boston, MA 02115, USA", + "", +] +# The rule above the footer ends here; the page number is set flush with it. +FOOTER_TAB = 9639 +# Paragraphs of the Foreword, by the text they start with. None removes the paragraph. +FRONT = { + "This Technical Specification (TS) has been produced by ": + f"This Technical Specification (TS) has been produced by {OWNER} in the context of a " + "collaborative effort to create a set of specifications that promote dApps interoperability. " + "The content of the present document is subject to continuing work and may change. " + "It will be re-released with an identifying change of version date and an increase " + "in version number as follows:", + "The content of the present document is subject to continuing work within O-RAN": None, +} +# Run text of the Modal verbs clause that names O-RAN. +MODAL = { + "O-RAN Drafting Rules": "ETSI Drafting Rules", + "allowed in O-RAN deliverables": "allowed in the present document", +} +# The O-RAN logo (image1) and a figure of the body that the cut leaves behind (image2). +DROP_MEDIA = ("word/media/image1.jpeg", "word/media/image2.png") + +PARA = r"](?:(?!).)*" +RUN = r"](?:(?!).)*?" +DRAWING_RUN = r"](?:(?!).)*?.*?" + def cut(document_xml: str) -> str: head_end = document_xml.index("") + len("") @@ -75,6 +116,99 @@ def cut(document_xml: str) -> str: return out +# --------------------------------------------------------------- front matter + + +def text(p): + return "".join(re.findall(r"]*>([^<]*)", p)) + + +def with_text(p, t): + """The paragraph with its properties, the first run's format and the text t.""" + head = re.match(r"]*>", p).group(0) + ppr = re.search(r".*?", p, re.S) + rpr = re.search(r"](?:(?!).)*?(.*?)", p, re.S) + runs = f'{rpr.group(1) if rpr else ""}{escape(t)}' if t else "" + return head + (ppr.group(0) if ppr else "") + runs + "" + + +def front_matter(x): + found = [] + + # The cover logo is a drawing in the document number paragraph (ZA). + za = re.search(r"]*>(?:(?!).)*?w:val=\"ZA\".*?", x, re.S).group(0) + x = x.replace(za, re.sub(DRAWING_RUN, "", za, flags=re.S), 1) + + # The rule across the middle of the cover is a border of the table that + # holds "Technical Specification" (ZB). + zb = x.index('w:val="ZB"') + start, end = x.rfind("", 0, zb), x.index("", zb) + x = x[:start] + re.sub(r'(]*/>', r'\1 w:val="nil"/>', x[start:end]) + x[end:] + + # The copyright page: one paragraph per line of COPYRIGHT, from "Copyright ©" on. + first = x.rfind("]*>", p).group(0) + ppr = re.search(r".*?", p, re.S).group(0) + ppr = re.sub(r".*?", "", ppr) + ppr = ppr.replace("" + "".join(field) + "" + return re.sub(PARA, fix, x, flags=re.S) + + +def drop_rels(x): + targets = "|".join(re.escape(m.split("word/", 1)[1]) for m in DROP_MEDIA) + return re.sub(rf']*Target="(?:{targets})"/>', "", x) + + +def template_part(name, data): + """One part of the template, made from the same part of the O-RAN specification.""" + if name in DROP_MEDIA: + return None + if name == "word/document.xml": + return front_matter(cut(data.decode("utf8"))).encode("utf8") + if name in ("word/header1.xml", "word/header2.xml"): + return re.sub(DRAWING_RUN, "", data.decode("utf8"), flags=re.S).encode("utf8") + if name in ("word/footer1.xml", "word/footer2.xml"): + return footer(data.decode("utf8")).encode("utf8") + if name.startswith("word/_rels/"): + return drop_rels(data.decode("utf8")).encode("utf8") + return data + + def main() -> int: if len(sys.argv) != 3: print(__doc__) @@ -82,10 +216,9 @@ def main() -> int: src, dst = sys.argv[1], sys.argv[2] with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout: for item in zin.infolist(): - data = zin.read(item.filename) - if item.filename == "word/document.xml": - data = set_group(cut(data.decode("utf8")), DEFAULT_GROUP).encode("utf8") - zout.writestr(item.filename, data) + data = template_part(item.filename, zin.read(item.filename)) + if data is not None: + zout.writestr(item, data) return 0 diff --git a/tools/oran_docx/template.docx b/tools/oran_docx/template.docx index 66f764ee..78e83bd7 100644 Binary files a/tools/oran_docx/template.docx and b/tools/oran_docx/template.docx differ