diff --git a/.cloud66/scripts/install-prince-xml.sh b/.cloud66/scripts/install-prince-xml.sh
index 8f52a8d..a233787 100644
--- a/.cloud66/scripts/install-prince-xml.sh
+++ b/.cloud66/scripts/install-prince-xml.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-set -e
+set -euo pipefail
if command -v prince >/dev/null 2>&1; then
echo "PrinceXML is already installed: $(command -v prince)"
@@ -10,19 +10,11 @@ fi
if ! command -v wget >/dev/null 2>&1; then
echo "wget is not installed. Installing wget..."
apt-get update
- apt-get install -yy wget
+ apt-get install -y wget
else
echo "wget is installed: $(command -v wget)"
fi
-if ! command -v gdebi >/dev/null 2>&1; then
- echo "gdebi is not installed. Installing gdebi..."
- apt-get update
- apt-get install -yy gdebi
-else
- echo "gdebi is installed: $(command -v gdebi)"
-fi
-
PRINCE_VERSION="16.1-1"
SYSTEM_ARCH="$(dpkg --print-architecture)"
@@ -44,28 +36,46 @@ else
exit 1
fi
-if [ "${ID:-}" = "ubuntu" ]; then
- PRINCE_PLATFORM="ubuntu22.04"
-elif [ "${ID:-}" = "debian" ]; then
- PRINCE_PLATFORM="debian12"
+# PrinceXML ships a separate package per Ubuntu LTS / Debian release whose
+# dependencies are pinned to that release (e.g. the ubuntu22.04 build requires
+# libavif13, which does not exist on Ubuntu 24.04 "noble" — noble ships
+# libavif16). The package MUST match the running release, not a hardcoded one.
+if [ "${ID:-}" = "ubuntu" ] && [ -n "${VERSION_ID:-}" ]; then
+ PRINCE_PLATFORM="ubuntu${VERSION_ID}"
+elif [ "${ID:-}" = "debian" ] && [ -n "${VERSION_ID:-}" ]; then
+ PRINCE_PLATFORM="debian${VERSION_ID%%.*}"
elif echo " ${ID_LIKE:-} " | grep -q " ubuntu "; then
PRINCE_PLATFORM="ubuntu22.04"
elif echo " ${ID_LIKE:-} " | grep -q " debian "; then
PRINCE_PLATFORM="debian12"
else
- echo "Unsupported Linux distribution for PrinceXML package: ${ID:-unknown}" >&2
+ echo "Unsupported Linux distribution for PrinceXML package: ${ID:-unknown} ${VERSION_ID:-}" >&2
exit 1
fi
+echo "Detected OS: ${ID:-unknown} ${VERSION_ID:-} -> PrinceXML platform: ${PRINCE_PLATFORM}, arch: ${PRINCE_ARCH}"
+
PRINCE_DEB_NAME="prince_${PRINCE_VERSION}_${PRINCE_PLATFORM}_${PRINCE_ARCH}.deb"
PRINCE_DEB_URL="https://www.princexml.com/download/${PRINCE_DEB_NAME}"
PRINCE_DEB_FILE="/tmp/${PRINCE_DEB_NAME}"
echo "Downloading PrinceXML package from $PRINCE_DEB_URL"
-wget -O "$PRINCE_DEB_FILE" "$PRINCE_DEB_URL"
+wget --tries=3 --timeout=60 -O "$PRINCE_DEB_FILE" "$PRINCE_DEB_URL"
+
+# Guard against a server returning an HTML error page with a 200 status.
+if ! file "$PRINCE_DEB_FILE" | grep -qi "debian binary package"; then
+ echo "Downloaded file is not a valid .deb package:" >&2
+ file "$PRINCE_DEB_FILE" >&2
+ exit 1
+fi
echo "Installing PrinceXML..."
-gdebi --non-interactive "$PRINCE_DEB_FILE"
+# apt-get installs a local .deb and resolves its dependencies itself
+# (apt >= 1.1). This avoids depending on gdebi, whose default package is the
+# GTK GUI frontend (gdebi, not gdebi-core) and is unavailable on minimal
+# server images.
+apt-get update
+apt-get install -y "$PRINCE_DEB_FILE"
if command -v prince >/dev/null 2>&1; then
echo "PrinceXML installed successfully: $(command -v prince)"
diff --git a/app/assets/stylesheets/gdoc.scss b/app/assets/stylesheets/gdoc.scss
new file mode 100644
index 0000000..f48ce62
--- /dev/null
+++ b/app/assets/stylesheets/gdoc.scss
@@ -0,0 +1,358 @@
+@charset "utf-8";
+
+// =========================================================================
+// LCMS Core Google Doc export defaults
+// =========================================================================
+// Spec: "LCMS Core Styling Defaults". Open items live in
+// docs/core/styling-defaults-needs-clarification.md.
+//
+// Loaded by app/views/layouts/gdoc.html.erb via inlined_asset('gdoc.css').
+// The HTML is uploaded to Google Drive with mime_type "application/vnd.
+// google-apps.document"; Google Drive imports it into a Google Doc.
+//
+// Notes on Google Docs HTML import:
+// * Tag selectors (h1, p, body) are honored on import.
+// * Class selectors are inconsistently honored — for styling that must
+// survive in the Gdoc, the safer path is to inline `style="..."` on
+// elements in the doc_template renderers (see Q-Gdoc-inline in the
+// clarification doc).
+// * @import url(...) for Google Fonts is unreliable on import. Lexend is
+// a native Google Fonts family in Google Docs, so naming it in
+// `font-family` is sufficient.
+// * @page / page-setup rules are ignored — Google Docs uses its own page
+// settings.
+// * Source-doc inline styles still ship through @document.css_styles in
+// the layout body. !important keeps our defaults visible until the
+// normalization pass (Q6) lands.
+
+// --- Design tokens (kept in sync with pdf.scss) --------------------------
+
+$font-family-body: "Lexend", Arial, sans-serif !default;
+$font-family-heading: $font-family-body !default;
+
+$color-text: #000000 !default;
+$color-text-muted: #434343 !default;
+
+$font-size-body: 11pt !default;
+$font-size-caption: 9pt !default;
+$line-height-body: 1.15 !default;
+
+$font-size-h1: 22pt !default;
+$font-size-h2: 18pt !default;
+$font-size-h3: 14pt !default;
+$font-size-h4: 13pt !default;
+$font-size-h5: 12pt !default;
+$font-size-h6: 11pt !default;
+
+$space-heading-large: 6pt !default;
+$space-heading-medium: 4pt !default;
+
+// --- Base / body ---------------------------------------------------------
+
+body {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ line-height: $line-height-body !important;
+ margin: 0;
+}
+
+p,
+span,
+li,
+td,
+th {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ line-height: $line-height-body !important;
+}
+
+p {
+ margin: 0;
+}
+
+ul,
+ol {
+ line-height: $line-height-body;
+}
+
+// --- Highlighting --------------------------------------------------------
+
+span,
+p {
+ background-color: transparent !important;
+}
+
+// --- Headings ------------------------------------------------------------
+
+%heading-base {
+ font-family: $font-family-heading !important;
+ font-weight: bold !important;
+ text-align: left !important;
+ margin-top: 0;
+}
+
+h1 {
+ @extend %heading-base;
+ font-size: $font-size-h1 !important;
+ color: $color-text !important;
+ margin-bottom: $space-heading-large;
+}
+
+h2 {
+ @extend %heading-base;
+ font-size: $font-size-h2 !important;
+ color: $color-text !important;
+ margin-bottom: $space-heading-large;
+}
+
+h3 {
+ @extend %heading-base;
+ font-size: $font-size-h3 !important;
+ color: $color-text-muted !important;
+ margin-bottom: $space-heading-medium;
+}
+
+h4 {
+ @extend %heading-base;
+ font-size: $font-size-h4 !important;
+ color: $color-text !important;
+ margin-bottom: 0;
+}
+
+h5 {
+ @extend %heading-base;
+ font-size: $font-size-h5 !important;
+ color: $color-text-muted !important;
+ margin-bottom: 0;
+}
+
+h6 {
+ @extend %heading-base;
+ font-size: $font-size-h6 !important;
+ color: $color-text !important;
+ font-style: italic !important;
+ margin-bottom: 0;
+}
+
+// --- Images --------------------------------------------------------------
+// Caption rendered by lib/doc_template/templates/gdoc/image.html.erb as a
+//
inside a layout table. Class-based, so
+// may be brittle through Google Docs import — verify per render.
+
+.o-ld-image__caption,
+figcaption {
+ font-family: $font-family-body !important;
+ font-size: $font-size-caption !important;
+ color: $color-text !important;
+ text-align: right;
+}
+
+// --- Material tag --------------------------------------------------------
+
+.o-ld-material {
+ font-style: italic !important;
+}
+
+// --- Lesson banner -------------------------------------------------------
+// Rendered by app/views/documents/gdoc/_header.html.erb. Mirrors the PDF
+// banner; class-selector support in Google Docs is limited, so we also
+// add tag-level rules via the H1 ladder above.
+
+.c-lesson-banner__strip {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0;
+
+ td {
+ padding: 0 !important;
+ border: 0;
+ vertical-align: middle;
+ }
+}
+
+.c-lesson-banner__brand {
+ width: 25%;
+ text-align: left;
+}
+
+.c-lesson-banner__brand-img {
+ max-height: 36pt;
+ max-width: 120pt;
+}
+
+.c-lesson-banner__type {
+ font-family: $font-family-body !important;
+ font-size: 12pt !important;
+ font-weight: bold !important;
+ color: $color-text !important;
+ text-align: right;
+}
+
+.c-lesson-banner__time {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ text-align: right;
+}
+
+.c-lesson-banner__divider {
+ border: 0;
+ border-top: 1pt solid $color-text;
+ margin: 4pt 0;
+}
+
+.c-lesson-banner__title {
+ margin-bottom: $space-heading-large;
+}
+
+.c-lesson-banner__standards {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ margin: 0 0 $space-heading-large 0;
+}
+
+// --- Lesson overview (description bullets) -------------------------------
+
+.c-lesson-overview__list {
+ margin: 0 0 $space-heading-large 0;
+ padding-left: 36pt;
+
+ li > p {
+ margin: 0;
+ padding: 0;
+ }
+}
+
+// --- Lesson vocabulary (inline line) -------------------------------------
+
+.c-lesson-vocabulary {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ margin: 0 0 $space-heading-large 0;
+}
+
+// --- Callout — inline (1-row 2-col) variant ------------------------------
+
+// --- Lesson materials summary (aggregated from activity-metadata) --------
+
+.c-lesson-materials__heading {
+ margin-bottom: 4pt;
+}
+
+.c-lesson-materials__table {
+ width: 100%;
+ border-collapse: collapse;
+ border: 1pt solid $color-text;
+ margin: 0 0 $space-heading-large 0;
+
+ th,
+ td {
+ border: 1pt solid $color-text;
+ padding: 4pt 8pt !important;
+ vertical-align: top;
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ }
+
+ th {
+ width: 30%;
+ text-align: left;
+ font-weight: bold !important;
+ }
+}
+
+// --- Activity (flowing layout) -------------------------------------------
+
+.o-ld-activity {
+ margin: $space-heading-large 0 0;
+
+ &__divider {
+ border: 0;
+ border-top: 1pt solid $color-text-muted;
+ margin: $space-heading-large 0;
+ }
+
+ &__heading {
+ margin-bottom: 4pt;
+ }
+
+ &__alert {
+ font-style: italic !important;
+ margin: 0 0 4pt 0;
+ }
+
+ &__meta,
+ &__materials,
+ &__metacognition,
+ &__standard {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ margin: 0 0 4pt 0;
+ }
+
+ &__guidance {
+ margin: $space-heading-large 0;
+ padding-left: 12pt;
+ border-left: 2pt solid $color-text-muted;
+
+ p {
+ margin: 0 0 2pt 0;
+ }
+ }
+}
+
+.o-ld-callout--inline {
+ margin: $space-heading-large 0;
+
+ .o-ld-callout__layout {
+ width: 100%;
+ border: 1pt solid $color-text;
+ border-collapse: collapse;
+ }
+
+ .o-ld-callout__label {
+ width: 25%;
+ padding: 8pt !important;
+ vertical-align: middle;
+ text-align: center;
+ border-right: 1pt solid $color-text;
+
+ p {
+ margin: 0;
+ padding: 0;
+ }
+ }
+
+ .o-ld-callout__icon {
+ font-family: $font-family-body !important;
+ font-size: 20pt;
+ font-weight: bold !important;
+ color: $color-text !important;
+ line-height: 1;
+ margin-bottom: 4pt;
+ }
+
+ .o-ld-callout__type {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ font-weight: 400 !important;
+ color: $color-text !important;
+ line-height: $line-height-body;
+ }
+
+ .o-ld-callout__body {
+ padding: 8pt 12pt !important;
+ vertical-align: middle;
+
+ p {
+ margin: 0;
+ padding: 0;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pdf.scss b/app/assets/stylesheets/pdf.scss
index e63c05c..30d40f5 100644
--- a/app/assets/stylesheets/pdf.scss
+++ b/app/assets/stylesheets/pdf.scss
@@ -1 +1,368 @@
@charset "utf-8";
+
+@import url("https://fonts.googleapis.com/css2?family=Lexend:wght@400;700&family=Nunito:wght@400;700&family=Roboto:wght@400;700&display=swap");
+
+// =========================================================================
+// LCMS Core PDF defaults
+// =========================================================================
+// Spec: "LCMS Core Styling Defaults". Open items live in
+// docs/core/styling-defaults-needs-clarification.md.
+//
+// Note on !important: imported Google-Doc HTML carries inline style="..." on
+// every / (font-family, font-size, color, line-height). Inline styles
+// beat any class selector, so spec-mandated overrides ("always overwrite color
+// and size to the preset body style") must use !important until the source-doc
+// CSS normalization pass (Q6) strips those inline attributes per-element.
+// Once that lands, !important here can be removed.
+
+// --- Design tokens -------------------------------------------------------
+
+$font-family-body: "Lexend", Arial, sans-serif !default;
+$font-family-heading: $font-family-body !default;
+
+$color-text: #000000 !default;
+$color-text-muted: #434343 !default;
+
+$font-size-body: 11pt !default;
+$font-size-caption: 9pt !default;
+$line-height-body: 1.15 !default;
+
+$font-size-h1: 22pt !default;
+$font-size-h2: 18pt !default;
+$font-size-h3: 14pt !default;
+$font-size-h4: 13pt !default;
+$font-size-h5: 12pt !default;
+$font-size-h6: 11pt !default;
+
+$space-heading-large: 6pt !default; // H1, H2 paragraph-after
+$space-heading-medium: 4pt !default; // H3 paragraph-after
+
+// --- Base / body ---------------------------------------------------------
+
+body {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ line-height: $line-height-body !important;
+ margin: 0;
+}
+
+p,
+span,
+li,
+td,
+th {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ line-height: $line-height-body !important;
+}
+
+p {
+ margin: 0;
+}
+
+ul,
+ol {
+ line-height: $line-height-body;
+}
+
+// --- Highlighting --------------------------------------------------------
+// Spec: not included by default; clients must request.
+// Strip Google-Doc inline background-color on inline text elements.
+
+span,
+p {
+ background-color: transparent !important;
+}
+
+// --- Headings ------------------------------------------------------------
+
+%heading-base {
+ font-family: $font-family-heading !important;
+ font-weight: bold !important;
+ text-align: left !important;
+ margin-top: 0;
+}
+
+h1 {
+ @extend %heading-base;
+ font-size: $font-size-h1 !important;
+ color: $color-text !important;
+ margin-bottom: $space-heading-large;
+}
+
+h2 {
+ @extend %heading-base;
+ font-size: $font-size-h2 !important;
+ color: $color-text !important;
+ margin-bottom: $space-heading-large;
+}
+
+h3 {
+ @extend %heading-base;
+ font-size: $font-size-h3 !important;
+ color: $color-text-muted !important;
+ margin-bottom: $space-heading-medium;
+}
+
+h4 {
+ @extend %heading-base;
+ font-size: $font-size-h4 !important;
+ color: $color-text !important;
+ margin-bottom: 0;
+}
+
+h5 {
+ @extend %heading-base;
+ font-size: $font-size-h5 !important;
+ color: $color-text-muted !important;
+ margin-bottom: 0;
+}
+
+h6 {
+ @extend %heading-base;
+ font-size: $font-size-h6 !important;
+ color: $color-text !important;
+ font-style: italic !important;
+ margin-bottom: 0;
+}
+
+// --- Images --------------------------------------------------------------
+// Caption rendered by lib/doc_template/templates/image.html.erb as
+// . The "credit" element from the spec is not yet present in
+// the template markup (tracked in clarification doc, Q7).
+
+figure {
+ margin: 0;
+}
+
+figcaption {
+ font-family: $font-family-body !important;
+ font-size: $font-size-caption !important;
+ color: $color-text !important;
+ text-align: right;
+}
+
+// --- Material tag --------------------------------------------------------
+// Rendered by lib/doc_template/tags/material_tag.rb as
+// .
+
+.o-ld-material {
+ font-style: italic !important;
+}
+
+// --- Lesson banner -------------------------------------------------------
+// Rendered by app/views/documents/pdf/_header.html.erb. Top-of-document
+// strip: brandmark | lesson type / estimated time, divider, H1, standards.
+
+.c-lesson-banner__strip {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0;
+
+ td {
+ padding: 0 !important;
+ border: 0;
+ vertical-align: middle;
+ }
+}
+
+.c-lesson-banner__brand {
+ width: 25%;
+ text-align: left;
+}
+
+.c-lesson-banner__brand-img {
+ max-height: 36pt;
+ max-width: 120pt;
+}
+
+.c-lesson-banner__type {
+ font-family: $font-family-body !important;
+ font-size: 12pt !important;
+ font-weight: bold !important;
+ color: $color-text !important;
+ text-align: right;
+}
+
+.c-lesson-banner__time {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ text-align: right;
+}
+
+.c-lesson-banner__divider {
+ border: 0;
+ border-top: 1pt solid $color-text;
+ margin: 4pt 0;
+}
+
+.c-lesson-banner__title {
+ // Inherits H1 defaults above; class exists for view-level targeting.
+ margin-bottom: $space-heading-large;
+}
+
+.c-lesson-banner__standards {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ margin: 0 0 $space-heading-large 0;
+}
+
+// --- Lesson overview (description bullets) -------------------------------
+// Rendered by app/views/documents/pdf/_header.html.erb, fed by the
+// description-past / description / description-future metadata.
+
+.c-lesson-overview__list {
+ margin: 0 0 $space-heading-large 0;
+ padding-left: 36pt;
+
+ li > p {
+ margin: 0;
+ padding: 0;
+ }
+}
+
+// --- Lesson vocabulary (inline line) -------------------------------------
+
+.c-lesson-vocabulary {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ margin: 0 0 $space-heading-large 0;
+}
+
+// --- Callout — inline (1-row 2-col) variant ------------------------------
+// Rendered by lib/doc_template/templates/callout_inline.html.erb when the
+// authored callout table has exactly one row. Side-by-side: icon+label on
+// the left, content on the right. The legacy 3-row shape uses
+// callout.html.erb and the existing .o-ld-callout / .o-ld-callout__header
+// styles (untouched).
+
+// --- Lesson materials summary (aggregated from activity-metadata) --------
+// Rendered above the activities by app/views/documents/pdf/_header.html.erb.
+// 5-row label/value table — empty values render as "None".
+
+.c-lesson-materials__heading {
+ margin-bottom: 4pt;
+}
+
+.c-lesson-materials__table {
+ width: 100%;
+ border-collapse: collapse;
+ border: 1pt solid $color-text;
+ margin: 0 0 $space-heading-large 0;
+
+ th,
+ td {
+ border: 1pt solid $color-text;
+ padding: 4pt 8pt !important;
+ vertical-align: top;
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ }
+
+ th {
+ width: 30%;
+ text-align: left;
+ font-weight: bold !important;
+ }
+}
+
+// --- Activity (flowing layout) -------------------------------------------
+// Rendered by lib/doc_template/templates/activity.html.erb. Drops the
+// legacy bordered-table wrapping in favor of an H3 heading + plain text
+// meta lines + body content, per the spec mockup.
+
+.o-ld-activity {
+ margin: $space-heading-large 0 0;
+
+ &__divider {
+ border: 0;
+ border-top: 1pt solid $color-text-muted;
+ margin: $space-heading-large 0;
+ }
+
+ &__heading {
+ // Inherits H3 typography (14pt bold #434343) from headings ladder above.
+ margin-bottom: 4pt;
+ }
+
+ &__alert {
+ font-style: italic !important;
+ margin: 0 0 4pt 0;
+ }
+
+ &__meta,
+ &__materials,
+ &__metacognition,
+ &__standard {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ color: $color-text !important;
+ margin: 0 0 4pt 0;
+ }
+
+ &__guidance {
+ margin: $space-heading-large 0;
+ padding-left: 12pt;
+ border-left: 2pt solid $color-text-muted;
+
+ p {
+ margin: 0 0 2pt 0;
+ }
+ }
+}
+
+.o-ld-callout--inline {
+ margin: $space-heading-large 0;
+
+ .o-ld-callout__layout {
+ width: 100%;
+ border: 1pt solid $color-text;
+ border-collapse: collapse;
+ }
+
+ .o-ld-callout__label {
+ width: 25%;
+ padding: 8pt !important;
+ vertical-align: middle;
+ text-align: center;
+ border-right: 1pt solid $color-text;
+
+ p {
+ margin: 0;
+ padding: 0;
+ }
+ }
+
+ .o-ld-callout__icon {
+ font-family: $font-family-body !important;
+ font-size: 20pt;
+ font-weight: bold !important;
+ color: $color-text !important;
+ line-height: 1;
+ margin-bottom: 4pt;
+ }
+
+ .o-ld-callout__type {
+ font-family: $font-family-body !important;
+ font-size: $font-size-body !important;
+ font-weight: 400 !important;
+ color: $color-text !important;
+ line-height: $line-height-body;
+ }
+
+ .o-ld-callout__body {
+ padding: 8pt 12pt !important;
+ vertical-align: middle;
+
+ p {
+ margin: 0;
+ padding: 0;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pdf_plain.scss b/app/assets/stylesheets/pdf_plain.scss
index e63c05c..8ba793a 100644
--- a/app/assets/stylesheets/pdf_plain.scss
+++ b/app/assets/stylesheets/pdf_plain.scss
@@ -1 +1,64 @@
@charset "utf-8";
+
+@import url("https://fonts.googleapis.com/css2?family=Lexend:wght@400;700&display=swap");
+
+// =========================================================================
+// LCMS Core PDF footer / "plain" chrome
+// =========================================================================
+// Loaded by app/views/layouts/pdf_plain.erb, which wraps the Grover
+// footer template (app/views/documents/pdf/_footer.erb). Grover renders
+// this in a separate Chromium document, so we re-declare the body font
+// and footer-specific styles here.
+
+$footer-font-family: "Lexend", Arial, sans-serif;
+$footer-color: #000000;
+$footer-color-muted: #434343;
+$footer-size-small: 9pt;
+$footer-size-bold: 10pt;
+
+body {
+ font-family: $footer-font-family;
+ color: $footer-color;
+ margin: 0;
+ padding: 0;
+}
+
+.c-lesson-footer {
+ font-family: $footer-font-family;
+ width: 100%;
+}
+
+.c-lesson-footer__copyright {
+ font-family: $footer-font-family;
+ font-size: $footer-size-small;
+ font-weight: 400;
+ color: $footer-color;
+ margin: 0 0 2pt 0;
+ padding: 0;
+}
+
+.c-lesson-footer__line {
+ width: 100%;
+ border-collapse: collapse;
+ border-top: 0.5pt solid $footer-color-muted;
+ margin-top: 2pt;
+
+ td {
+ padding: 4pt 0 0 0;
+ border: 0;
+ vertical-align: middle;
+ font-family: $footer-font-family;
+ font-size: $footer-size-bold;
+ font-weight: bold;
+ color: $footer-color;
+ }
+}
+
+.c-lesson-footer__breadcrumb {
+ text-align: left;
+}
+
+.c-lesson-footer__page {
+ text-align: right;
+ width: 36pt;
+}
diff --git a/app/helpers/asset_helper.rb b/app/helpers/asset_helper.rb
index 076d21e..4ef31fc 100644
--- a/app/helpers/asset_helper.rb
+++ b/app/helpers/asset_helper.rb
@@ -1,7 +1,12 @@
# frozen_string_literal: true
+require "open-uri"
+
module AssetHelper
REDIS_PREFIX = "ub-b64-asset"
+ DATA_URI_FETCH_LIMIT = 5.megabytes
+ DATA_URI_OPEN_TIMEOUT = 5
+ DATA_URI_READ_TIMEOUT = 10
class << self
def base64_encoded(path, cache: false)
@@ -17,6 +22,33 @@ def base64_encoded(path, cache: false)
b64_asset
end
+ # Fetches a remote (or local) URL and returns a base64 data URI suitable
+ # for embedding in HTML that gets imported by Google Drive (which strips
+ # or fails to fetch external image references during HTML→Gdoc import).
+ # Returns nil and logs a warning on failure so callers can fall back.
+ def inline_data_uri(url, cache: false)
+ return nil if url.blank?
+
+ key = "#{REDIS_PREFIX}-data-uri:#{Digest::SHA1.hexdigest(url)}"
+ if cache
+ cached = redis.get(key)
+ return cached if cached.present?
+ end
+
+ content = fetch_remote(url)
+ return nil if content.blank?
+
+ mime = mime_for(url, content)
+ encoded = Base64.strict_encode64(content)
+ data_uri = "data:#{mime};base64,#{encoded}"
+
+ redis.set(key, data_uri, ex: 1.day.to_i) if cache
+ data_uri
+ rescue StandardError => e
+ Rails.logger.warn "AssetHelper.inline_data_uri failed for #{url}: #{e.message}"
+ nil
+ end
+
def inlined(path)
if Rails.env.development? || Rails.env.test? || Rails.env.qa?
asset = Rails.application.assets.find_asset(path)
@@ -47,5 +79,34 @@ def encode(path)
def redis
Rails.application.config.redis
end
+
+ def fetch_remote(url)
+ uri = URI.parse(url)
+ case uri.scheme
+ when "http", "https"
+ uri.open(
+ open_timeout: DATA_URI_OPEN_TIMEOUT,
+ read_timeout: DATA_URI_READ_TIMEOUT,
+ content_length_proc: ->(size) {
+ if size && size > DATA_URI_FETCH_LIMIT
+ raise "remote asset too large: #{size} bytes"
+ end
+ }
+ ) { |io| io.read(DATA_URI_FETCH_LIMIT + 1) }.then do |body|
+ raise "remote asset exceeds #{DATA_URI_FETCH_LIMIT} bytes" if body.bytesize > DATA_URI_FETCH_LIMIT
+
+ body
+ end
+ else
+ raise "unsupported URL scheme: #{uri.scheme.inspect}"
+ end
+ end
+
+ def mime_for(url, content)
+ ext = File.extname(URI.parse(url).path).delete_prefix(".").downcase
+ return "image/svg+xml" if ext == "svg" || content.byteslice(0, 256).to_s.lstrip.start_with?(" |