From f3df9cc07885ee7740561bc60487f436b633b2d9 Mon Sep 17 00:00:00 2001 From: Behzad Samadi Date: Sat, 3 Oct 2026 11:04:25 -0400 Subject: [PATCH 1/2] A Concordia University profile, and PDF/A for any thesis The Concordia profile makes the thesis as the School of Graduate Studies' templates do, doctoral or master's: title and signature pages (i and ii, not numbered), abstract and other front pages in roman numerals from iii, a table of contents and lists of figures and tables as Word fields, chapters from 1 each on a new page. The front pages are built by universities/concordia/front-pages.lua from _quarto-concordia.yml; Word sections do the numbering. `pixi run concordia` downloads Concordia's official template and builds the reference document on the student's computer, so nothing of Concordia's is redistributed here. `pixi run pdf` opens the Word file in LibreOffice, fills in the contents and lists, and exports PDF/A-2b, through a macro in a throwaway profile. CI builds plain and Concordia (doctoral; master's on Linux) theses on all three systems, the PDF/A on Linux and Windows, and checks the sections and the PDF/A marker. Tested locally on macOS, including the PDF. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmzgbBq9swiYXP9W9tgbS2 --- .github/scripts/check_output.py | 32 ++ .github/workflows/check.yml | 46 ++- .gitignore | 4 + AGENTS.md | 12 + README.md | 25 ++ _quarto-concordia.yml | 36 +++ _quarto.yml | 5 + pixi.lock | 393 +++++++++++++++++++++++++ pixi.toml | 3 + tools/concordia_template.py | 104 +++++++ tools/pdf.py | 104 +++++++ universities/concordia/front-pages.lua | 203 +++++++++++++ 12 files changed, 959 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/check_output.py create mode 100644 _quarto-concordia.yml create mode 100644 tools/concordia_template.py create mode 100644 tools/pdf.py create mode 100644 universities/concordia/front-pages.lua diff --git a/.github/scripts/check_output.py b/.github/scripts/check_output.py new file mode 100644 index 0000000..129330e --- /dev/null +++ b/.github/scripts/check_output.py @@ -0,0 +1,32 @@ +"""CI check of what `pixi run word` (and `pixi run pdf`) made. + + python .github/scripts/check_output.py [concordia] +""" +import glob +import re +import sys +import zipfile + +docx = glob.glob("_book/*.docx") +assert docx, "no Word file in _book/" +xml = zipfile.ZipFile(docx[0]).read("word/document.xml").decode() +print("Word file:", docx[0]) + +if "concordia" in sys.argv[1:]: + sections = re.findall(r"", xml, re.S) + found = [] + for s in sections: + fmt = re.search(r'w:fmt="(\w+)"', s) + start = re.search(r'w:start="(\d+)"', s) + found.append((fmt.group(1) if fmt else "decimal", start.group(1) if start else None, "footerReference" in s)) + expected = [("lowerRoman", "1", False), ("lowerRoman", "3", True), ("decimal", "1", True)] + assert found == expected, f"sections {found}, expected {expected}" + for text in ["SCHOOL OF GRADUATE STUDIES", "Abstract", "Table of Contents"]: + assert text in xml, f"front pages lack {text!r}" + print("Concordia sections: title/signature unnumbered, front pages from iii, chapters from 1") + +pdfs = glob.glob("_book/*.pdf") +if pdfs: + data = open(pdfs[0], "rb").read() + assert re.search(rb"pdfaid:part(>|=\")2", data), "PDF isn't marked PDF/A-2" + print("PDF:", pdfs[0], "PDF/A-2") diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c2a7739..df7ef38 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,8 +1,9 @@ name: Check -# Builds the example thesis on Windows, macOS and Linux: pixi install, import -# the Word chapters, render the Word file. Runs only in the template itself, -# not in theses made from it. +# Builds the example thesis on Windows, macOS and Linux, plain and with the +# Concordia profile (doctoral everywhere, master's on Linux), and the PDF/A +# on Linux and Windows. Runs only in the template itself, not in theses made +# from it. on: push: branches: [main] @@ -16,6 +17,11 @@ jobs: fail-fast: false matrix: os: [windows-latest, macos-latest, ubuntu-latest] + thesis: [plain, concordia-doctoral, concordia-masters] + exclude: + - { os: windows-latest, thesis: concordia-masters } + - { os: macos-latest, thesis: concordia-masters } + name: ${{ matrix.thesis }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -28,14 +34,38 @@ jobs: - name: Import the Word chapters run: pixi run import - - name: Render the thesis in the template + - name: Concordia profile + if: startsWith(matrix.thesis, 'concordia') + shell: bash + run: | + pixi run concordia + echo "QUARTO_PROFILE=concordia" >> "$GITHUB_ENV" + + - name: Master's + if: matrix.thesis == 'concordia-masters' + shell: bash + run: sed -i.bak 's/^ degree-level: doctoral.*/ degree-level: masters/' _quarto-concordia.yml + + - name: Render the thesis run: pixi run word - - name: The Word file was made + - name: LibreOffice (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libreoffice-writer + + - name: LibreOffice (Windows) + if: runner.os == 'Windows' + run: choco install libreoffice-fresh -y --no-progress + + - name: PDF/A + if: runner.os != 'macOS' + run: pixi run pdf + + - name: Check what was made shell: bash - run: ls -l _book/*.docx + run: pixi run python .github/scripts/check_output.py ${{ startsWith(matrix.thesis, 'concordia') && 'concordia' || '' }} - uses: actions/upload-artifact@v4 with: - name: thesis-${{ matrix.os }} - path: _book/*.docx + name: ${{ matrix.thesis }}-${{ matrix.os }} + path: _book/*.* diff --git a/.gitignore b/.gitignore index 6ca07d7..f1b5292 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ _book/ /.quarto/ **/*.quarto_ipynb + +# Built on your computer from Concordia's official template by `pixi run concordia`. +universities/concordia/reference.docx +universities/concordia/footer.txt diff --git a/AGENTS.md b/AGENTS.md index 63698ff..c7afbad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,8 @@ template. | `pixi run import` | Converts each `word/*.docx` to a chapter, gathers citations into `references.json`, updates the chapter list in `_quarto.yml` | | `pixi run word` | The whole thesis as one Word file in `template.docx`, in `_book/` | | `pixi run preview` | A live preview in the browser | +| `pixi run pdf` | The Word file as PDF/A, contents and lists filled in (needs LibreOffice) | +| `pixi run concordia` | Once, for a Concordia thesis: builds `universities/concordia/reference.docx` from Concordia's official template | ## Moving their chapters in @@ -43,6 +45,16 @@ template. (Persian, Arabic, Hebrew), uncomment `lang` and `dir` there. 5. Run `pixi run word` and tell them where the file is. +## A Concordia University thesis + +1. `pixi run concordia` (downloads Concordia's template; never commit what it builds). +2. Uncomment `profile: default: concordia` in `_quarto.yml`. +3. Fill in `_quarto-concordia.yml` with the author: degree level (`doctoral` or + `masters`), title, department, degree, date, committee, abstract, optional + pages. Ask them for each value; don't invent names or dates. +4. `pixi run word`, then `pixi run pdf`. Check the page numbers with them: + title and signature pages unnumbered, abstract iii, first chapter 1. + ## Known limits, to say plainly - The template gives the Word file its styles, margins, headers and page diff --git a/README.md b/README.md index edce6e0..ad6055b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,31 @@ own), then run `pixi run word` again. Or ask an AI assistant (Claude Code, Codex) to do it: `AGENTS.md` tells it the rules, starting with *never change your wording*. +## A Concordia University thesis + +The Concordia profile makes the thesis the way the School of Graduate +Studies' templates do, doctoral or master's: the title page and signature +page (counted i and ii, not numbered), the abstract and other front pages in +roman numerals from iii, a table of contents and lists of figures and tables, +and the chapters from 1. + +1. `pixi run concordia`, once. It downloads Concordia's official template from + concordia.ca and builds the styles from it on your computer. +2. In `_quarto.yml`, uncomment `profile:` and `default: concordia`. +3. In `_quarto-concordia.yml`, fill in your degree (`doctoral` or `masters`), + title, department, committee, abstract and the optional pages. +4. `pixi run word`, then `pixi run pdf` for the PDF/A you submit. + +Check the result against the [Thesis Preparation Guide](https://www.concordia.ca/content/dam/sgs/docs/handbooks/thesispreparationguide.pdf): +this starter follows Concordia's templates but isn't made or checked by +Concordia. + +## The PDF you submit + +`pixi run pdf` turns the Word file into PDF/A, the archival PDF most +universities ask for, with the table of contents and lists filled in. It +needs [LibreOffice](https://www.libreoffice.org) (free), installed once. + ## فارسی پایان‌نامه‌تان در Word است و دانشگاه یک الگوی Word داده. این پوشه فصل‌ها را به diff --git a/_quarto-concordia.yml b/_quarto-concordia.yml new file mode 100644 index 0000000..cfc2f7a --- /dev/null +++ b/_quarto-concordia.yml @@ -0,0 +1,36 @@ +# Concordia University thesis: the School of Graduate Studies' formatting +# (title and signature pages, roman front pages from iii, chapters from 1). +# Switch it on in _quarto.yml (profile: default: concordia). Fill in yours. +format: + docx: + reference-doc: universities/concordia/reference.docx + toc: false # the front pages carry Concordia's own table of contents + +filters: + - universities/concordia/front-pages.lua + +concordia: + degree-level: doctoral # or: masters + title: "Archives and Their Silences" + author: "A. Researcher" + department: "History" + degree: "Doctor of Philosophy (History)" # master's: e.g. "Master of Arts (History)" + degree-short: "Ph.D." # doctoral abstract line only + date: "April 2027" # month and year of initial submission + year: "2027" + committee: + - { role: "Chair", name: "Dr. Chair Name" } + - { role: "External Examiner", name: "Dr. External Examiner" } + - { role: "Examiner", name: "Dr. Examiner One" } + - { role: "Examiner", name: "Dr. Examiner Two" } + - { role: "Thesis Supervisor", name: "Dr. Supervisor Name" } + program-director: "Dr. Program Director, Graduate Program Director" + dean: "Dr. Dean Name" + faculty: "Faculty of Arts and Science" + abstract: | + Colonial archives were not neutral collections. This thesis reads two of + them along and against their grain, and asks what their silences can tell. + acknowledgments: | + To my supervisor, and to the archivists who found the boxes. + list-of-figures: true + list-of-tables: true diff --git a/_quarto.yml b/_quarto.yml index 892cbcd..e7594eb 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -1,6 +1,11 @@ project: type: book +# A Concordia University thesis: run `pixi run concordia` once, uncomment the +# two lines below, and fill in _quarto-concordia.yml. +# profile: +# default: concordia + book: title: "Archives and Their Silences" author: "A. Researcher" diff --git a/pixi.lock b/pixi.lock index 9557db8..25bf8ce 100644 --- a/pixi.lock +++ b/pixi.lock @@ -38,6 +38,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h81df57d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.2.0-ha9f2e26_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h0cb94f2_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libpython-3.12.14-h0c77377_3_cpython.conda @@ -45,7 +46,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.4-hcfc3c73_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.45-h8e12856_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lxml-6.1.3-py312h663fcd5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.4-h781a0a9_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pandoc-3.8.3-ha770c72_0.conda @@ -56,9 +61,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/typst-0.14.2-h4c46f8d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-docx-1.2.0-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-9_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-docx-1.2.0-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-9_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h374f1ed_10.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/dart-sass-1.105.1-h8822bef_0.conda @@ -68,10 +79,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-23.1.2-h19cb2f5_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.5-h6e061ac_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.7.0-h6b3a05b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h4ae439b_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libpython-3.12.14-h31af0e7_3_cpython.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.4-ha5db789_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.4-h341e563_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.4-h3668fe6_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxslt-1.1.45-h31ee24d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/lxml-6.1.3-py312hf99fef4_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.4-h332eb6d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/pandoc-3.8.3-h694c41f_0.conda @@ -82,6 +98,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/typst-0.14.2-h121f529_0.conda osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-docx-1.2.0-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-9_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/dart-sass-1.105.1-h820172f_0.conda @@ -92,10 +111,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.2-h55c6f16_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.5-h6111c0a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-h47dc5ef_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpython-3.12.14-h4e5ec87_3_cpython.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-hca69786_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-h550178d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxslt-1.1.45-haaf9281_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lxml-6.1.3-py312haba262f_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.4-h55eecbc_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandoc-3.8.3-hce30654_0.conda @@ -106,18 +130,27 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/typst-0.14.2-hd1458d2_0.conda win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-docx-1.2.0-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-9_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda - conda: https://conda.anaconda.org/conda-forge/win-64/dart-sass-1.105.1-h11686cb_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/deno-2.4.5-h9135563_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/deno-dom-0.1.41-h1a6af48_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/esbuild-0.28.1-h11686cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.5-hac47afa_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libpython-3.12.14-hcc31f7b_3_cpython.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.4-h3cfd58e_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.4-he095d88_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.45-ha5384fd_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/lxml-6.1.3-py312h1476a5e_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.4-hf411b9b_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pandoc-3.8.3-h57928b3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.12.14-hb12b558_3_cpython.conda @@ -279,6 +312,18 @@ packages: - _openmp_mutex >=4.5 size: 640566 timestamp: 1790303046034 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h0cb94f2_3.conda + sha256: f943117edb9cd4d9c61cc972eee5a34291dc55ea7a6e9e38da104995841cbcb6 + md5: f92233bf33e24a25668bb2119e2c51f9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 789471 + timestamp: 1787033836207 - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda sha256: 9787df8c22a59c9a70d3e5a10db9ad663485e75e9ccc3f09bd092cb7b95e0dab md5: 1390b7c5ac0b1d8e447bc5efa6d3c8c2 @@ -370,6 +415,57 @@ packages: - libxcrypt >=4.4.38 size: 101957 timestamp: 1785887123445 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_0.conda + sha256: 9a1685c8d8b8488cd7882a9cd6e673e687211ca763ebbcd1e91b6f594eeadc45 + md5: b7545c57a73a51d72aa6eee5695cf051 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=15 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - libxml2 2.15.4 + license: MIT + license_family: MIT + run_exports: {} + size: 569196 + timestamp: 1788635197456 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_0.conda + sha256: 053d85821ce5e36b7ce03faea5930382f6af695df9e2cd68e5d8d273072d1551 + md5: ea192d1ef556bf1d55de4acb8c0f5b65 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=15 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.4 hf3af7cc_0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.4 + size: 47077 + timestamp: 1788635202698 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.45-h8e12856_1.conda + sha256: 3beb51b5ce8d2e0690b8517d3d955972d502cfbe2899279cbb9cbb6ff134d360 + md5: 5c8ce6af1772fcfc2b86d17b825abc8e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + - libxml2 + - libxml2-16 >=2.15.3 + license: MIT + license_family: MIT + run_exports: + weak: + - libxslt >=1.1.45,<2.0a0 + size: 250314 + timestamp: 1788534852474 - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda sha256: eb8a0db0aa570124f7d2a93d7c7f596e3390df5e047818d873baad32985fc736 md5: 0de0122d9570a8ab637c6b73db268389 @@ -384,6 +480,22 @@ packages: - libzlib >=1.3.2,<2.0a0 size: 63713 timestamp: 1785362952714 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lxml-6.1.3-py312h663fcd5_0.conda + sha256: 7ae87ca339fdc617c370a07da78ff8e950ceddf22f46fc2cac61c9f8ebafc84a + md5: 208336d5a9afe597e8a6d7004b108ef0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + - libxml2 + - libxml2-16 >=2.15.4 + - libxslt >=1.1.45,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause and MIT-CMU + run_exports: {} + size: 1595760 + timestamp: 1789036626350 - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda sha256: 5d46557214ed184381dafe835b7c94a474a1c3b307a08a250b1ea4779b44ffb3 md5: ee6c0cd80a60961a1f48aa3e0b91f986 @@ -546,6 +658,40 @@ packages: run_exports: {} size: 131780 timestamp: 1784754889428 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-docx-1.2.0-pyhff2d567_0.conda + sha256: de6faf10475638f53788b56e26f92520be685255980476171f44d4b0fd8e46bf + md5: acd1d843d04fec96fa33240eb4d70347 + depends: + - lxml >=3.1.0 + - python >=3.9 + - typing_extensions >=4.9.0 + license: MIT + license_family: MIT + run_exports: {} + size: 178844 + timestamp: 1750157583631 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-9_cp312.conda + build_number: 9 + sha256: ee9c2922e07afc85fc82d5fa82c9ac2c79da3be3283a5e17bf2f2ae38dbd02fb + md5: 4c32076993e6270825441d059ab5c18b + constrains: + - python 3.12.* *_cpython + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6751 + timestamp: 1788302823694 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + sha256: 2d888f90af0686044882c74193ec80a90ec1943145d94a7b1b048958acda1848 + md5: c70ad746c22219b9700931707482992c + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + run_exports: {} + size: 52631 + timestamp: 1783002732887 - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda sha256: b928c30ddcb0e3f544c6eade8352737e6e610e263276b90232db6a578ef899d8 md5: fcb489df604d100968b737f2cb6076c6 @@ -643,6 +789,17 @@ packages: - libffi >=3.7.0,<3.8.0a0 size: 60786 timestamp: 1787754056669 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h4ae439b_3.conda + sha256: 2389e03a58ed0f5e8f2469eb4d4ba80d0af378b38854ae5eee65e5b641244082 + md5: 9fd2f77288f43e462ccc6b0e5f018c89 + depends: + - __osx >=11.0 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 736150 + timestamp: 1787034707041 - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_1.conda sha256: 7915dac7c71c208e40e716e2f6d3eff41a8d5584e0e7c2d46f9bf9bd5f9aa739 md5: daf49067580fbfeae3ac7f9535400494 @@ -679,6 +836,55 @@ packages: - libsqlite >=3.53.4,<4.0a0 size: 1008994 timestamp: 1787051932723 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.4-h341e563_0.conda + sha256: bc284057b85e4a0ecc158598d81c3081fa58e9111969a05d3056ab7a8442c837 + md5: 73b097ecff26dd04ebb0133f12e6d768 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - icu <0.0a0 + - libxml2 2.15.4 + license: MIT + license_family: MIT + run_exports: {} + size: 498699 + timestamp: 1788635665366 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.4-h3668fe6_0.conda + sha256: 9cf91223637981568c8fbd5c4dbbc1e6137592e919828554ae436bf259aa91d0 + md5: 49f8bc2da3e704b8520a0be94e43d360 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.4 h341e563_0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - icu <0.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.4 + size: 41401 + timestamp: 1788635683771 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libxslt-1.1.45-h31ee24d_1.conda + sha256: dc8019d66fcf3427d58e9db681c425a354412e6b1d013a2e910e6b9feefc27a8 + md5: 0bb77e56de62b1d3531a2ac3fd38fcfd + depends: + - __osx >=11.0 + - libxml2 + - libxml2-16 >=2.15.3 + license: MIT + license_family: MIT + run_exports: + weak: + - libxslt >=1.1.45,<2.0a0 + size: 226568 + timestamp: 1788535186058 - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda sha256: b2dba286dd6632292b12296e761193b5ef9fb0eaeecaa481f5ba9af72c0c18e1 md5: 7d3fa28263bb7f8ea32db11f570a5bb6 @@ -693,6 +899,21 @@ packages: - libzlib >=1.3.2,<2.0a0 size: 58993 timestamp: 1785276808631 +- conda: https://conda.anaconda.org/conda-forge/osx-64/lxml-6.1.3-py312hf99fef4_0.conda + sha256: 57c0374c5043d501714e4ae5d40df35af33eac57c4eeb05de475cc5963e4fe9b + md5: 795c91b6142367f8b9e210ec3914b378 + depends: + - __osx >=11.0 + - libxml2 + - libxml2-16 >=2.15.4 + - libxslt >=1.1.45,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause and MIT-CMU + run_exports: {} + size: 1344632 + timestamp: 1789037034862 - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_1.conda sha256: 12c1d676b9a0e8109b576672519312c5428308f3f3d7706f8717e2f536d5d9e7 md5: 88a79390f87c8f3334b9999a892e78d4 @@ -908,6 +1129,17 @@ packages: - libffi >=3.7.0,<3.8.0a0 size: 43637 timestamp: 1787753403688 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda + sha256: 689a14968267f2f97c07112fca5636e7d756036b9aee969911267c20bd3eea1f + md5: 17f4744c0873e9f77ac9b5cd0a27c187 + depends: + - __osx >=11.0 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 750816 + timestamp: 1787033961086 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda sha256: 23d0630046a3e8b164d8f80f2b74ed2605af2e7050ab9913018056402fae4311 md5: 8ab10323068b107661a4b9a4af84f3b5 @@ -945,6 +1177,54 @@ packages: - libsqlite >=3.53.4,<4.0a0 size: 942754 timestamp: 1787051243846 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_0.conda + sha256: 8a26f7c94ab849c1930da36c298f4b82e892382b70ae865cd7274188d9d783e2 + md5: b40633f711a5eb8617917e0e944b9965 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - libxml2 2.15.4 + license: MIT + license_family: MIT + run_exports: {} + size: 469299 + timestamp: 1788635201153 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-h550178d_0.conda + sha256: 81217fcd4d4abdc69ee63cdb9cdb22e05a20b9ba7a76f596579914a838fe5960 + md5: 4b97a34d06740b578c32deba195daf5a + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.4 heb56d2d_0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.4 + size: 41375 + timestamp: 1788635206084 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxslt-1.1.45-haaf9281_1.conda + sha256: 4b40785bdc7af110db10c9b951ce7f82c515bb371046367f0e397428d2db44f4 + md5: 9e4ab938b0971629481134a16b766937 + depends: + - __osx >=11.0 + - libxml2 + - libxml2-16 >=2.15.3 + license: MIT + license_family: MIT + run_exports: + weak: + - libxslt >=1.1.45,<2.0a0 + size: 221755 + timestamp: 1788535395293 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda sha256: a18fa5d5bac452401459f966cf0d872224e8080c4ff93c77e168d43ab42ef9d7 md5: f39288f0ea63ae962e1a2e4f355a0d75 @@ -959,6 +1239,21 @@ packages: - libzlib >=1.3.2,<2.0a0 size: 47822 timestamp: 1785277049190 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lxml-6.1.3-py312haba262f_0.conda + sha256: 8f4aa4fc8c0227cdf4cbe9adf8e51704c5d82b686842a8cb0e46c3d11450bd9d + md5: 6bbb82f69ac1cc00375cebcd3e7d594a + depends: + - __osx >=11.0 + - libxml2 + - libxml2-16 >=2.15.4 + - libxslt >=1.1.45,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause and MIT-CMU + run_exports: {} + size: 1280564 + timestamp: 1789036616184 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda sha256: 7024a48c8c0d0114ed4ab53c76bf9275d50e91ba7cea367a9aead638d3c29c68 md5: 3dfa0d0316dc246cd44937a557de4501 @@ -1130,6 +1425,20 @@ packages: run_exports: {} size: 4387697 timestamp: 1781248857546 +- conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda + sha256: 75c549b55b673e15de8785a8e5dd85bca7eb612eee0ff4dc8d7bdaa15eacbdbb + md5: e596942e8ee6ee17fdcf1e6a77757a66 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 16835644 + timestamp: 1784916416303 - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.5-hac47afa_0.conda sha256: 70c73d9fd24ad996f2821d1501adae2ca70984b4e2d8faed76227f348ce86078 md5: 4969128c45f9e71adbb6a1696679ce7f @@ -1158,6 +1467,19 @@ packages: - libffi >=3.7.0,<3.8.0a0 size: 49992 timestamp: 1787753517346 +- conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_3.conda + sha256: 35e04e3ddac7720fc7c550a1c9f998604299d6a7bd1f3ec9b2825d825061daa2 + md5: a8a2abdf0f901bc4779d9b7be0845921 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 694899 + timestamp: 1787033851701 - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda sha256: d36c4a1e1f80fd08e18a407e03622ff2f34dfdd022da6488ad19603dea19e6d5 md5: 880a0c8549479b198af21ba5dc49b109 @@ -1198,6 +1520,60 @@ packages: - libsqlite >=3.53.4,<4.0a0 size: 1314919 timestamp: 1787051140039 +- conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.4-h3cfd58e_0.conda + sha256: 9fa71cec30425c9cc8eda6af3574af1faf4fefa5e239c9e037b1f778ef3ce8ac + md5: bab5489f4bd38494ffaa83d6ab924c1f + depends: + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - libxml2 2.15.4 + license: MIT + license_family: MIT + run_exports: {} + size: 515800 + timestamp: 1788635268845 +- conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.4-he095d88_0.conda + sha256: a19bb3821830f6b1281e782844d5dd265f796dfed2e4812b16c9995a8c778c88 + md5: 3ce54d0f8f24dfab99d82414411640aa + depends: + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.4 h3cfd58e_0 + - libzlib >=1.3.2,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.4 + size: 43845 + timestamp: 1788635276599 +- conda: https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.45-ha5384fd_1.conda + sha256: 7a2402fa33b7e4a99bba6250ce21c36269a8acbfcb3652057dce0f4a60b7f6bd + md5: ea7b3abfe39c180d1dce620867e267d1 + depends: + - libxml2 + - libxml2-16 >=2.15.3 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + run_exports: + weak: + - libxslt >=1.1.45,<2.0a0 + size: 421402 + timestamp: 1788534969422 - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda sha256: 0629c2cc0404d3bb29d6baa7b4ba62da80797015e86de050db81ea5a07050527 md5: 5d2ff29d465097458cc3ff6569151991 @@ -1214,6 +1590,23 @@ packages: - libzlib >=1.3.2,<2.0a0 size: 58529 timestamp: 1785276664143 +- conda: https://conda.anaconda.org/conda-forge/win-64/lxml-6.1.3-py312h1476a5e_0.conda + sha256: d60a0470d35881b4c3df3967c0e1ab98f2eecdc273812f573cc7f7c373677fe1 + md5: 1e2cfcfd5315fd67ab506653c9173346 + depends: + - libxml2 + - libxml2-16 >=2.15.4 + - libxslt >=1.1.45,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause and MIT-CMU + run_exports: {} + size: 1273740 + timestamp: 1789036778093 - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.4-hf411b9b_0.conda sha256: 9dddb559ba49744d5d94092d8d13cb0567f5c3b3f439f3acf28433d1f4256acc md5: 73cb1783f47c452be4c309430fbef89f diff --git a/pixi.toml b/pixi.toml index c2f4781..2fb659a 100644 --- a/pixi.toml +++ b/pixi.toml @@ -9,9 +9,12 @@ platforms = ["win-64", "osx-arm64", "osx-64", "linux-64"] quarto = "1.9.*" pandoc = "3.8.*" python = "3.12.*" +python-docx = "1.*" [tasks] import = { cmd = "python tools/import_word.py", description = "Convert every Word chapter in word/ (never overwrites a converted chapter)" } word = { cmd = "quarto render --to docx", description = "The whole thesis as one Word file, in template.docx, in _book/" } html = { cmd = "quarto render --to html", description = "The thesis as a website, in _book/" } preview = { cmd = "quarto preview", description = "A live preview that updates as you save" } +pdf = { cmd = "python tools/pdf.py", description = "The Word file as PDF/A, contents filled in (needs LibreOffice)" } +concordia = { cmd = "python tools/concordia_template.py", description = "Once, for a Concordia thesis: build the reference document from Concordia's official template" } diff --git a/tools/concordia_template.py b/tools/concordia_template.py new file mode 100644 index 0000000..85328d3 --- /dev/null +++ b/tools/concordia_template.py @@ -0,0 +1,104 @@ +"""Build the Concordia reference document from Concordia's official template. + + pixi run concordia + +Run once, on your computer, before switching on the Concordia profile. It +downloads the School of Graduate Studies' thesis template from concordia.ca +(nothing of Concordia's is stored in this repository), keeps its styles, page +setup and page-number footer, and writes: + + universities/concordia/reference.docx the styles your Word file gets + universities/concordia/footer.txt which footer carries the page number + +Both degrees use the same styles and page setup; the front pages that differ +(doctoral or master's) are written by front-pages.lua from _quarto-concordia.yml. +""" + +import re +import sys +import urllib.request +import zipfile +from io import BytesIO +from pathlib import Path + +from docx import Document +from docx.enum.style import WD_STYLE_TYPE +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.shared import Pt + +ROOT = Path(__file__).resolve().parent.parent +OUT = ROOT / "universities" / "concordia" +SOURCE = "https://www.concordia.ca/content/dam/sgs/docs/resources/doctoral-thesis-template.doc" +PAGE = "https://www.concordia.ca/gradstudies/students/thesis-based/process.html" + + +def download() -> bytes: + try: + with urllib.request.urlopen(SOURCE, timeout=60) as r: + data = r.read() + except OSError as e: + sys.exit(f"Couldn't download Concordia's template ({e}).\nCheck {PAGE} for its current address.") + # Concordia names it .doc, but it is a Word 2007+ (.docx) file inside. + if not zipfile.is_zipfile(BytesIO(data)) or "word/document.xml" not in zipfile.ZipFile(BytesIO(data)).namelist(): + sys.exit(f"The file at {SOURCE} isn't the Word template any more.\nCheck {PAGE}.") + return data + + +def build(data: bytes) -> str: + doc = Document(BytesIO(data)) + body = doc.element.body + final = body.find(qn("w:sectPr")) # the chapters' section: arabic numbering from 1 + footer = final.find(qn("w:footerReference")) if final is not None else None + if footer is None: + sys.exit("Concordia's template no longer has a page-number footer where expected; the Concordia profile needs updating.") + for child in list(body): + if child is not final: + body.remove(child) + + styles = doc.styles + names = {s.name for s in styles} + + def add(name, base="Normal", align=None, bold=None, size=None, after=None): + s = styles[name] if name in names else styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH) + s.base_style = styles[base] + s.quick_style = True + if align is not None: + s.paragraph_format.alignment = align + if bold is not None: + s.font.bold = bold + if size: + s.font.size = Pt(size) + if after is not None: + s.paragraph_format.space_after = Pt(after) + + # The front pages (front-pages.lua). + add("Front Centered", align=WD_ALIGN_PARAGRAPH.CENTER, after=12) + add("Front Title", align=WD_ALIGN_PARAGRAPH.CENTER, bold=True, size=16, after=24) + add("Front Heading", align=WD_ALIGN_PARAGRAPH.CENTER, bold=True, size=14, after=18) + add("Front Text", align=WD_ALIGN_PARAGRAPH.LEFT, after=12) + # Styles pandoc writes that Concordia's template doesn't define, so they + # follow the template's own Normal and Caption. + for name in ["Body Text", "First Paragraph", "Compact", "Captioned Figure", "Bibliography"]: + if name not in names: + add(name) + for name in ["Image Caption", "Table Caption"]: + if name not in names: + add(name, base="Caption" if "Caption" in names else "Normal") + # Each chapter on a new page. + styles["Heading 1"].paragraph_format.page_break_before = True + + OUT.mkdir(parents=True, exist_ok=True) + doc.save(OUT / "reference.docx") + return footer.get(qn("r:id")) + + +def main() -> None: + rid = build(download()) + (OUT / "footer.txt").write_text(rid + "\n", encoding="utf-8") + print(f"universities/concordia/reference.docx: built from Concordia's template (page numbers in {rid})") + print("Next: switch on the profile in _quarto.yml, fill in _quarto-concordia.yml, then `pixi run word`.") + + +if __name__ == "__main__": + main() diff --git a/tools/pdf.py b/tools/pdf.py new file mode 100644 index 0000000..58e32d7 --- /dev/null +++ b/tools/pdf.py @@ -0,0 +1,104 @@ +"""The thesis as PDF/A, from the Word file `pixi run word` made. + + pixi run pdf + +Uses LibreOffice (free; install it once from libreoffice.org). It opens the +Word file, fills in the table of contents and the lists of figures and tables +(Word leaves those for the reader to update), and exports PDF/A-2b, the +archival PDF universities such as Concordia ask for. + +LibreOffice is driven by a small macro in a throwaway profile, so nothing in +your own LibreOffice settings is touched. +""" + +import glob +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK = ROOT / "_book" + +MACRO = """Sub ExportPdfA(src As String, dst As String) + Dim load(0) As New com.sun.star.beans.PropertyValue + load(0).Name = "Hidden" : load(0).Value = True + doc = StarDesktop.loadComponentFromURL(ConvertToURL(src), "_blank", 0, load()) + ' Twice: filling the contents moves pages, and the second pass gets the numbers right. + For pass = 1 To 2 + indexes = doc.getDocumentIndexes() + For i = 0 To indexes.getCount() - 1 + indexes.getByIndex(i).update() + Next i + doc.getTextFields().refresh() + Next pass + Dim pdf(0) As New com.sun.star.beans.PropertyValue + pdf(0).Name = "SelectPdfVersion" : pdf(0).Value = 2 + Dim export(1) As New com.sun.star.beans.PropertyValue + export(0).Name = "FilterName" : export(0).Value = "writer_pdf_Export" + export(1).Name = "FilterData" : export(1).Value = pdf() + doc.storeToURL(ConvertToURL(dst), export()) + doc.close(True) +End Sub +""" + +DTD = '\n\n' + + +def soffice() -> str: + found = shutil.which("soffice") or shutil.which("libreoffice") + if found: + return found + candidates = { + "Darwin": ["/Applications/LibreOffice.app/Contents/MacOS/soffice"], + "Windows": [r"C:\Program Files\LibreOffice\program\soffice.exe", + r"C:\Program Files (x86)\LibreOffice\program\soffice.exe"], + }.get(platform.system(), ["/usr/bin/soffice", "/usr/lib/libreoffice/program/soffice"]) + for c in candidates: + if os.path.exists(c): + return c + sys.exit("LibreOffice isn't installed. Install it from https://www.libreoffice.org, then run this again.") + + +def profile_with_macro(base: Path, office: str) -> Path: + """A fresh LibreOffice profile whose Standard library holds MACRO. + + LibreOffice builds a new profile on its first start, so let it, then + replace the empty Module1 it made with MACRO.""" + subprocess.run([office, "--headless", "--norestore", "--terminate_after_init", + f"-env:UserInstallation={base.as_uri()}"], capture_output=True, timeout=300) + module = base / "user" / "basic" / "Standard" / "Module1.xba" + if not module.exists(): + sys.exit("LibreOffice didn't set up its profile; is it installed correctly?") + escaped = MACRO.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + module.write_text( + DTD.format(kind="script:module", dtd="module.dtd") + + '' + + escaped + "\n", encoding="utf-8") + return base + + +def main() -> None: + sources = sorted(glob.glob(str(BOOK / "*.docx"))) + if not sources: + sys.exit("No Word file in _book/. Run `pixi run word` first.") + src = Path(sources[0]) + dst = src.with_suffix(".pdf") + with tempfile.TemporaryDirectory() as tmp: + office = soffice() + profile = profile_with_macro(Path(tmp), office) + macro = f'macro:///Standard.Module1.ExportPdfA("{src}","{dst}")' + result = subprocess.run( + [office, "--headless", "--norestore", "--nologo", + f"-env:UserInstallation={profile.as_uri()}", macro], + capture_output=True, text=True, timeout=600) + if not dst.exists(): + sys.exit(f"LibreOffice didn't make the PDF.\n{result.stdout}{result.stderr}") + print(f"{dst.relative_to(ROOT)}: PDF/A-2b, contents and lists filled in") + + +if __name__ == "__main__": + main() diff --git a/universities/concordia/front-pages.lua b/universities/concordia/front-pages.lua new file mode 100644 index 0000000..d0d2f03 --- /dev/null +++ b/universities/concordia/front-pages.lua @@ -0,0 +1,203 @@ +--[[ +Concordia University front pages, for Word output. + +Builds what the School of Graduate Studies' thesis templates (doctoral and +master's) put before the first chapter, from the `concordia:` settings in +_quarto-concordia.yml: + + title page and signature page counted i and ii, no number shown + abstract, optional pages, lower-case roman from iii + table of contents, lists + chapters arabic from 1 (reference.docx's own section) + +The wording follows the official templates. Word sections do the numbering: +each front block ends with a section break carrying its own page setup. +]] + +-- reference.docx's page-number footer: pandoc keeps the reference's +-- relationship ids, and `pixi run concordia` writes this one to footer.txt +-- when it builds reference.docx from Concordia's template. +local function footer_rid() + local f = io.open(quarto.utils.resolve_path("footer.txt"), "r") + if not f then + error("universities/concordia/footer.txt is missing: run `pixi run concordia` first.") + end + local rid = f:read("*l"); f:close() + return rid +end +local FOOTER_RID = footer_rid() +local PAGE = '' + .. '' + +local stringify = pandoc.utils.stringify + +local function esc(s) + return (s:gsub("&", "&"):gsub("<", "<"):gsub(">", ">")) +end + +local function raw(xml) return pandoc.RawBlock("openxml", xml) end + +local PAGE_BREAK = raw('') + +local function section_break(numbering, footer) + return raw('' + .. (footer and ('') or '') + .. PAGE .. numbering .. '') +end + +-- A paragraph in one of reference.docx's front-page styles. +local function styled(style, inlines) + return pandoc.Div({ pandoc.Para(inlines) }, pandoc.Attr("", {}, { { "custom-style", style } })) +end + +local function line(style, text) return styled(style, { pandoc.Str(text) }) end + +-- One paragraph of text and tabs, as raw Word XML: "By:name". `stops` +-- are tab positions in twentieths of a point (1440 = one inch); `before` is +-- space above the paragraph, in the same unit. +local function tabbed(parts, stops, before) + local tabs = {} + for _, pos in ipairs(stops or {}) do + tabs[#tabs + 1] = '' + end + local runs = {} + for i, p in ipairs(parts) do + if i > 1 then runs[#runs + 1] = '' end + runs[#runs + 1] = '' .. esc(p) .. '' + end + return raw('' + .. (#tabs > 0 and ('' .. table.concat(tabs) .. '') or '') + .. '' + .. table.concat(runs) .. '') +end + +-- The examining committee: a borderless two-column table, signature line +-- and name on the left, role on the right, as in the official template. +local function committee_table(members) + local rows = {} + for _, m in ipairs(members) do + local cell = function(text, width) + return '' + .. '' + .. esc(text) .. '' + end + rows[#rows + 1] = '' + .. cell('_________________________________ ' .. stringify(m.name or ''), 6480) + .. cell(stringify(m.role or ''), 2880) .. '' + end + return raw('' + .. '' + .. '' + .. '' + .. table.concat(rows) .. '') +end + +-- A Word field (table of contents, list of figures): Word fills it when +-- the document is opened; tools/pdf.py updates it before making the PDF. +local function field(instruction, placeholder) + return raw('' + .. ' ' .. instruction .. ' ' + .. '' + .. '' .. esc(placeholder) .. '' + .. '') +end + +local function blocks_of(value) + if value == nil then return nil end + if pandoc.utils.type(value) == "Blocks" then return value end + if pandoc.utils.type(value) == "Inlines" then return pandoc.Blocks({ pandoc.Para(value) }) end + return pandoc.Blocks({ pandoc.Para({ pandoc.Str(stringify(value)) }) }) +end + +local function optional_page(out, heading, value) + local content = blocks_of(value) + if not content then return end + out:insert(PAGE_BREAK) + out:insert(line("Front Heading", heading)) + out:extend(content) +end + +function Pandoc(doc) + if not FORMAT:match("docx") then return nil end + local c = doc.meta.concordia + if not c then return nil end + + local level = stringify(c["degree-level"] or "doctoral") + local doctoral = level ~= "masters" + local title, author = stringify(c.title or ""), stringify(c.author or "") + local department, degree = stringify(c.department or ""), stringify(c.degree or "") + local date, year = stringify(c.date or ""), stringify(c.year or "") + + local out = pandoc.Blocks({}) + + -- Title page (i, unnumbered). + out:insert(line("Front Title", title)) + for _, t in ipairs({ author, "A Thesis", "In the Department", "of", department, + "Presented in Partial Fulfillment of the Requirements" }) do + out:insert(line("Front Centered", t)) + end + if doctoral then + out:insert(line("Front Centered", "For the Degree of")) + out:insert(line("Front Centered", degree)) + out:insert(line("Front Centered", "at Concordia University")) + else + out:insert(line("Front Centered", "for the Degree of " .. degree .. " at")) + out:insert(line("Front Centered", "Concordia University")) + end + out:insert(line("Front Centered", "Montréal, Québec, Canada")) + out:insert(line("Front Centered", date)) + out:insert(line("Front Centered", "© " .. author .. ", " .. year)) + + -- Signature page (ii, unnumbered). + out:insert(PAGE_BREAK) + out:insert(line("Front Heading", "CONCORDIA UNIVERSITY")) + out:insert(line("Front Heading", "SCHOOL OF GRADUATE STUDIES")) + out:insert(line("Front Text", "This is to certify that the thesis prepared")) + out:insert(tabbed({ "By:", author }, { 2160 })) + out:insert(tabbed({ "Entitled:", title }, { 2160 })) + out:insert(line("Front Text", "and submitted in partial fulfillment of the requirements for the degree of")) + out:insert(line("Front Centered", doctoral and degree:upper() or degree)) + out:insert(line("Front Text", "complies with the regulations of the University and meets the accepted standards with respect to originality and quality.")) + out:insert(line("Front Text", "Signed by the final examining committee:")) + out:insert(committee_table(c.committee or {})) + local director = stringify(c["program-director"] or (doctoral and "Graduate Program Director" + or "Chair of Department or Graduate Program Director")) + local dean = stringify(c.dean or (doctoral and "Dean" or "Dean of Faculty")) + .. (c.faculty and (", " .. stringify(c.faculty)) or "") + out:insert(tabbed({ "Approved by", "_______________________________________________" }, { 2160 }, 480)) + out:insert(tabbed({ "", director }, { 2160 })) + out:insert(tabbed({ "__________________", "_______________________________________________" }, { 2880 }, 480)) + out:insert(tabbed({ "Date", dean }, { 2880 })) + out:insert(section_break('', false)) + + -- Abstract (iii) and the optional pages. + out:insert(line("Front Heading", "Abstract")) + out:insert(line("Front Centered", title)) + out:insert(line("Front Centered", doctoral and (author .. ", " .. stringify(c["degree-short"] or "Ph.D.")) or author)) + if doctoral then out:insert(line("Front Centered", "Concordia University, " .. year)) end + out:extend(blocks_of(c.abstract) or pandoc.Blocks({})) + optional_page(out, "Summary", c.summary) + optional_page(out, "Acknowledgments", c.acknowledgments) + optional_page(out, "Dedication", c.dedication) + optional_page(out, "Contribution of Authors", c["contribution-of-authors"]) + + out:insert(PAGE_BREAK) + out:insert(line("Front Heading", "Table of Contents")) + out:insert(field('TOC \\o "1-3" \\h \\z \\u', "Table of contents: update fields to fill")) + if c["list-of-figures"] ~= false then + out:insert(PAGE_BREAK) + out:insert(line("Front Heading", "List of Figures")) + out:insert(field('TOC \\h \\z \\t "Image Caption,1"', "List of figures: update fields to fill")) + end + if c["list-of-tables"] ~= false then + out:insert(PAGE_BREAK) + out:insert(line("Front Heading", "List of Tables")) + out:insert(field('TOC \\h \\z \\t "Table Caption,1"', "List of tables: update fields to fill")) + end + out:insert(section_break('', true)) + + -- The title page replaces Quarto's title block. + for _, k in ipairs({ "title", "subtitle", "author", "date", "abstract" }) do doc.meta[k] = nil end + doc.blocks = out .. doc.blocks + return doc +end From e5e6ba0949deb7d2bb8337feb1bfc5cc05b0a4c0 Mon Sep 17 00:00:00 2001 From: Behzad Samadi Date: Sat, 3 Oct 2026 11:05:42 -0400 Subject: [PATCH 2/2] CI: the master's step's sed command in a block scalar (YAML read its ': ' as a key) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmzgbBq9swiYXP9W9tgbS2 --- .github/workflows/check.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index df7ef38..b14bfa6 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -44,7 +44,9 @@ jobs: - name: Master's if: matrix.thesis == 'concordia-masters' shell: bash - run: sed -i.bak 's/^ degree-level: doctoral.*/ degree-level: masters/' _quarto-concordia.yml + run: | + sed -i.bak 's/^ degree-level: doctoral.*/ degree-level: masters/' _quarto-concordia.yml + grep -n 'degree-level' _quarto-concordia.yml - name: Render the thesis run: pixi run word