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?(" "activity-materials-student", + "Pair Materials" => "activity-materials-pair", + "Small Group Materials" => "activity-materials-group", + "Class Materials" => "activity-materials-class", + "Teacher Materials" => "activity-metadata-teacher" + }.freeze + + def brandmark_url + raw = Settings.get(:documents, include_defaults: true)&.dig(:brandmark) + return nil if raw.blank? + + # Inline as data URI so the image survives HTML→Gdoc import (and the + # gdoc_pdf renderer, which routes PDF through Drive). Falls back + # to the raw URL if the fetch fails — works for Grover/Chromium. + AssetHelper.inline_data_uri(raw, cache: ViewHelper::ENABLE_BASE64_CACHING) || raw + end + + def copyright_text + Settings.get(:documents, include_defaults: true)&.dig(:copyright_text).presence + end + + # Bold breadcrumb line used in the lesson footer. + # @return [String, nil] e.g. "Grade 6/Course • Unit Title • Lesson 2" + def footer_breadcrumb + parts = [grade_label, unit_title, lesson_label].compact_blank + parts.any? ? parts.join(" • ") : nil + end + + # Aggregates activity-metadata material fields into the 5-row lesson + # Materials summary table. Each row collects values across all + # activities, dedupes, joins, and resolves any [material: id] tokens + # to italicized identifier links (matching how MaterialTag renders + # inline). Empty rows render as "None". + # + # @return [Hash{String => String}] heading => joined materials HTML. + # Returns {} when the document has no activity metadata so the view + # can skip the Materials block entirely. + def materials_summary + activities = Array.wrap(activity_metadata) + return {} if activities.empty? + + MATERIALS_ROWS.transform_values do |key| + values = activities.flat_map { |a| split_list(a[key]) }.uniq.compact_blank + next "None" if values.empty? + + values.map { |v| resolve_material_tokens(v) }.join(", ") + end + end + def content_for(context_type, options = {}) render_content(context_type, options) end @@ -17,12 +69,23 @@ def description # Footer data for Google Apps Script post-processing. # Used in Google::ScriptService#parameters. # + # NOTE: kept at the original 2-row shape. The R2 footer design (copyright + # line + breadcrumb) is fully implemented in the PDF footer. To land it in + # generated Gdocs we need to (a) update the Apps Script template doc in + # Drive to use new placeholders AND (b) extend this array. Doing only (b) + # makes the Apps Script post-processing hang on unfamiliar args. + # # @return [Array>] 2D array with placeholder/value pairs: # [["{placeholder}"], [replacement_value]] def gdoc_footer [ ["{attribution}"], - [cc_attribution.presence || "Copyright attribution here"] + [ + [copyright_text.presence, cc_attribution.presence] + .compact + .join(" — ") + .presence || "Copyright attribution here" + ] ] end @@ -84,4 +147,45 @@ def short_title def standards base_metadata.standards end + + private + + def grade_label + return nil if grade.blank? + + "Grade #{grade}/Course" + end + + def unit_title + resource&.ancestors&.find(&:unit?)&.title.presence || + (unit_id.present? ? "Unit #{unit_id.to_s.upcase}" : nil) + end + + def lesson_label + lesson_number.to_i.positive? ? "Lesson #{lesson_number}" : nil + end + + def split_list(value) + return [] if value.blank? + + value.to_s.split(",").map(&:strip).reject(&:blank?) + end + + # Replaces `[material: id]` tokens in raw activity-metadata text with the + # italicized identifier markup that MaterialTag emits inline. Plain text + # is passed through unchanged. + MATERIAL_TOKEN_RE = /\[material:\s*([^\]]+)\]/i + + def resolve_material_tokens(text) + text.to_s.gsub(MATERIAL_TOKEN_RE) do + identifier = ::Regexp.last_match(1).to_s.strip + next identifier if identifier.blank? + + if ::Material.exists?(identifier: identifier.downcase) + %(#{identifier}) + else + identifier + end + end + end end diff --git a/app/services/html_sanitizer.rb b/app/services/html_sanitizer.rb index b2446b7..a7a5122 100644 --- a/app/services/html_sanitizer.rb +++ b/app/services/html_sanitizer.rb @@ -336,16 +336,13 @@ def post_processing_images_gdoc(nodes) css = ":not(.u-ld-not-image-wrap) > img:not([src*=googleapis]):not(.o-ld-icon):not(.o-ld-latex)" nodes.css(css).each do |img| img = img.parent.replace(img) if %w(span p).include?(img.parent.name) + # Use a
wrapper (not a ) so Drive's HTML→Gdoc import + # doesn't add a border around the image. Matches the PDF wrapping + # in #post_processing_images. img.replace(%( -
- - - -
-
- #{img} -
-
+
+ #{img} +

)) end diff --git a/app/uploaders/image_uploader.rb b/app/uploaders/image_uploader.rb index fdc1c00..e075923 100644 --- a/app/uploaders/image_uploader.rb +++ b/app/uploaders/image_uploader.rb @@ -13,6 +13,21 @@ def store_dir "uploads/settings" end + # When stored on S3, return the unsigned public URL so that the URL + # persisted to Setting (and later embedded in PDF/Gdoc exports) doesn't + # expire. With fog_public = false (required by buckets that have ACLs + # disabled), CarrierWave's default url returns a short-lived presigned + # URL, which would break image embeds in generated documents. + def url(*args) + return super unless self.class.storage == CarrierWave::Storage::Fog + + bucket = ENV.fetch("AWS_S3_BUCKET_NAME", nil) + return super if bucket.blank? || path.blank? + + region = ENV.fetch("AWS_REGION", "us-east-1") + "https://#{bucket}.s3.#{region}.amazonaws.com/#{path}" + end + def filename ext = original_filename.present? ? File.extname(original_filename) : extension_fallback @filename_cache ||= "#{SecureRandom.hex(8)}#{ext}" diff --git a/app/views/admin/settings/show/_text.html.erb b/app/views/admin/settings/show/_text.html.erb new file mode 100644 index 0000000..92c888c --- /dev/null +++ b/app/views/admin/settings/show/_text.html.erb @@ -0,0 +1,8 @@ +" +> diff --git a/app/views/documents/gdoc/_content.html.erb b/app/views/documents/gdoc/_content.html.erb index 701cc28..f91040a 100644 --- a/app/views/documents/gdoc/_content.html.erb +++ b/app/views/documents/gdoc/_content.html.erb @@ -1,5 +1 @@ -<% if document.description.present? %> -

Lesson Summary

- <%= raw document.description %> -<% end %> <%= raw document.content_for(:gdoc, options) %> diff --git a/app/views/documents/gdoc/_header.html.erb b/app/views/documents/gdoc/_header.html.erb index 8c7103f..13792c7 100644 --- a/app/views/documents/gdoc/_header.html.erb +++ b/app/views/documents/gdoc/_header.html.erb @@ -1,12 +1,51 @@ -

<%= document.lesson_title %>

-<% if document.teaser.present? %> -

- <%= raw document.teaser %> -

-<% end %> + + + + + + + + +
+ <% if document.brandmark_url.present? %> + + <% end %> + + <%= document.lesson_type.to_s.titleize %> +
+ <% if document.estimated_time.present? %> + <%= t("lesson.banner.estimated_time_label") %>: <%= document.estimated_time %> + <% end %> +
+
+

<%= document.lesson_title %>

<% if document.standards.present? %> -

STANDARDS: <%= document.standards&.upcase %>

+

<%= document.standards %>

+<% end %> + +<% overview_items = [document.description_past, document.description, document.description_future].reject(&:blank?) %> +<% if overview_items.any? %> +

<%= t("lesson.overview.heading") %>

+ +<% end %> + +<% if document.vocabulary.present? %> +

<%= t("lesson.vocabulary.label") %>: <%= document.vocabulary %>

+<% end %> + +<% materials_summary = document.materials_summary %> +<% if materials_summary.any? %> +

<%= t("lesson.materials.heading") %>

+ + <% materials_summary.each do |label, value| %> + + + + + <% end %> +
<%= label %><%= raw value %>
<% end %> -
-
-
diff --git a/app/views/documents/pdf/_footer.erb b/app/views/documents/pdf/_footer.erb index c1a44c0..1ff1bb7 100644 --- a/app/views/documents/pdf/_footer.erb +++ b/app/views/documents/pdf/_footer.erb @@ -1,24 +1,14 @@ -
- + - - - - - + diff --git a/app/views/documents/pdf/_header.html.erb b/app/views/documents/pdf/_header.html.erb index 73ea3e2..77669ef 100644 --- a/app/views/documents/pdf/_header.html.erb +++ b/app/views/documents/pdf/_header.html.erb @@ -1,11 +1,53 @@ -
-

<%= document.lesson_title %>

-
- <%= raw document.teaser %> -
-
- <% if document.standards.present? %> -

Standards: <%= document.standards %>

- <% end %> -
+
+ + + + + + + + +
+ <% if document.brandmark_url.present? %> + + <% end %> + + <%= document.lesson_type.to_s.titleize %> +
+ <% if document.estimated_time.present? %> + <%= t("lesson.banner.estimated_time_label") %>: <%= document.estimated_time %> + <% end %> +
+
+

<%= document.lesson_title %>

+ <% if document.standards.present? %> +

<%= document.standards %>

+ <% end %> + + <% overview_items = [document.description_past, document.description, document.description_future].reject(&:blank?) %> + <% if overview_items.any? %> +

<%= t("lesson.overview.heading") %>

+
    + <% overview_items.each do |item| %> +
  • <%= raw item %>
  • + <% end %> +
+ <% end %> + + <% if document.vocabulary.present? %> +

<%= t("lesson.vocabulary.label") %>: <%= document.vocabulary %>

+ <% end %> + + <% materials_summary = document.materials_summary %> + <% if materials_summary.any? %> +

<%= t("lesson.materials.heading") %>

+ + <% materials_summary.each do |label, value| %> + + + + + <% end %> +
<%= label %><%= raw value %>
+ <% end %>
diff --git a/config/initializers/carrier_wave.rb b/config/initializers/carrier_wave.rb index 4239889..0794070 100644 --- a/config/initializers/carrier_wave.rb +++ b/config/initializers/carrier_wave.rb @@ -13,7 +13,10 @@ region: ENV.fetch("AWS_REGION", nil) } config.fog_directory = ENV.fetch("AWS_S3_BUCKET_NAME", nil) - config.fog_public = true + # Bucket has "Bucket owner enforced" ownership — ACLs are disabled. + # Sending x-amz-acl on upload would 400. Public read is granted via + # bucket policy; ImageUploader#url returns the unsigned public URL. + config.fog_public = false config.storage = :fog end diff --git a/config/initializers/lcms_constants.rb b/config/initializers/lcms_constants.rb index 72410e9..c972c6a 100644 --- a/config/initializers/lcms_constants.rb +++ b/config/initializers/lcms_constants.rb @@ -54,6 +54,10 @@ header_logo: :image }, admin_view_links: :form, + documents: { + brandmark: :image, + copyright_text: :text + }, pdf_renderer: { default_renderer: :renderer_select }, diff --git a/config/locales/admin/en.yml b/config/locales/admin/en.yml index d445ec7..780683c 100644 --- a/config/locales/admin/en.yml +++ b/config/locales/admin/en.yml @@ -310,11 +310,13 @@ en: groups: appearance: Appearance admin_view_links: Admin View Links + documents: Documents pdf_renderer: PDF Renderer pdf: PDF Page Geometry descriptions: appearance: Customize the appearance of the admin panel interface. admin_view_links: "\"View\" link URL patterns shown on the admin index pages, one per line. Use :id as the record placeholder." + documents: Defaults applied to document and material exports (PDF and Google Doc). pdf_renderer: Which registered PDF renderer the app uses by default. Leave on the system default unless you have a specific reason to pin one. Only renderers whose runtime dependencies are installed appear here. pdf: Page geometry (margins, DPI, orientation, padding) used when generating PDFs, per content type. Edit the values below and save; the change takes effect on the next PDF generation. Use Reset to restore the shipped defaults. labels: @@ -324,6 +326,9 @@ en: header_dropdown_bg_color: Header Dropdown Background Color header_active_item_color: Header Active Item Color header_logo: Header Logo + documents: + brandmark: Brandmark + copyright_text: Copyright Text (e.g., "© Company Name, Spring 2026") pdf_renderer: default_renderer: Default PDF renderer renderer_select: diff --git a/config/locales/en.yml b/config/locales/en.yml index 1d3cbbc..f27af0c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -12,6 +12,15 @@ en: "application/msword": doc "application/vnd.openxmlformats-officedocument.wordprocessingml.document": openoffice document "application/vnd.ms-powerpoint": powerpoint + lesson: + banner: + estimated_time_label: "Estimated Time" + materials: + heading: "Materials" + overview: + heading: "Overview" + vocabulary: + label: "Vocabulary" grades: ela: grade_9: Grade 9 diff --git a/docs/core/lesson-metadata-specs.md b/docs/core/lesson-metadata-specs.md index a4748a4..94d5018 100644 --- a/docs/core/lesson-metadata-specs.md +++ b/docs/core/lesson-metadata-specs.md @@ -9,10 +9,12 @@ | lesson-title-Spanish | text; can be blank | | lesson-label | Options: required, optional; can be blank | | lesson-type | text Options: | +| estimated-time | text; free-form duration shown in the lesson banner (e.g., "2 Class Periods", "45 minutes"); can be blank | | standards | unique alphanumeric codes; comma separated list (e.g., MS-ESS2-4, MS-ESS2-5); codes will connect to spreadsheet with standards language for rendering | | description | text that describes the lesson: “In this lesson, we…” | | description-past | text that describes the lesson in past tense language; “In the previous lesson, we…”; will be blank for the last lesson of a unit | | description-future | text that describes the lesson in future tense language; “In the next lesson, we will…”; will be blank for Lesson 1 | +| vocabulary | text, comma separated list; lesson-level vocabulary shown in the lesson banner (e.g., "word 1, word 2, word 3"); can be blank. Distinct from activity-metadata `vocabulary`. | | learning-targets | text | | lms-enabled | Yes or No | | lms-summary | text; blank if not LMS enabled | diff --git a/docs/core/styling-defaults-needs-clarification.md b/docs/core/styling-defaults-needs-clarification.md new file mode 100644 index 0000000..ddee6e6 --- /dev/null +++ b/docs/core/styling-defaults-needs-clarification.md @@ -0,0 +1,501 @@ +# LCMS Core Styling Defaults — Needs Clarification + +Working notes for the "LCMS Core Styling Defaults" initiative. The source spec +("LCMS Core Styling Defaults" — Google Doc) is mostly clear at the typography +and page-layout level. This file tracks open questions, decisions already made, +and items that must be answered before the corresponding implementation phase +can start. + +## Scope (working assumption) + +This initiative covers the **default Grover/Chromium-rendered** Lesson, Material, +and Bundle exports (PDF + Google Doc). PrinceXML is **out of scope** here and +will be delivered as a separate plugin in its own PR/branch. + +--- + +## Resolved decisions + +### R1. PrinceXML — out of scope +PrinceXML support will be implemented in a separate PR and branch. The defaults +shipped by this initiative target the existing Grover/Chromium pipeline only. + +### R2. Lesson document footer — confirmed design +Footer placement: bottom of page, separated from page content by a horizontal +rule above the footer block. + +Footer contents (two rows under the divider line, left-aligned with page number +right-aligned on the second row): + +- Row 1: `© , ` — regular weight, small (~9–10 pt). + - Example: `© Company Name, Spring 2026`. +- Row 2 (bold): + - Left: `/Course • • Lesson <N>` + - Right: `<page number>` + +Open follow-ups for the footer (deferred to Q-related items below): +- Source of `Company Name` and `Season Year` — config? unit/lesson metadata? +- Whether the same footer pattern applies to **Materials** and **Bundles**, or + if those get their own designs (see Q2 below). + +### R3. Phase-1 SCSS uses `!important` as a temporary hammer +The first SCSS pass in `app/assets/stylesheets/pdf.scss` uses `!important` on +font-family, font-size, color, and line-height for body/paragraph/span/li/td/th +and on heading rules. Reason: imported Google-Doc HTML carries inline +`style="font-family: ...; font-size: ...; color: ..."` on every element, and +inline styles beat any class selector. Without `!important`, our defaults have +no effect. + +This is consistent with the spec's "we will always overwrite color and size to +the preset body style" requirement. However, it **also** force-overrides the +per-element allowlist preservation (an author using Arial — which is on the +allowlist — will still be rendered in Lexend). That trade-off is removed once +the source-doc CSS normalization pass (Q6) ships and strips the inline style +attributes per-element; at that point the `!important` modifiers can be +removed. + +Also delivered in this slice: Lexend loaded via Google Fonts `@import` (per +user choice on Q5 — answered for this phase only; full Q5 stays open). + +### R4. Google Doc export defaults — parallel stylesheet shipped +Before this slice, `app/views/layouts/gdoc.html.erb` referenced `gdoc.css` +via `inlined_asset('gdoc.css')` but **no such file existed and the build +chain didn't compile one** — Gdoc exports shipped with zero LCMS-applied +styling. This slice adds: + +- `app/assets/stylesheets/gdoc.scss` with the same design tokens as + `pdf.scss` (Lexend body, H1–H6, image caption, material tag, italic). +- A new compile entry and `build:css:prefix:gdoc` step in `package.json`. + +Key deviations from `pdf.scss`: + +- **No Google Fonts `@import url(...)`** — Google Drive's HTML→Gdoc import + doesn't reliably honor it. Lexend is recognized as a native Google Fonts + family in Google Docs, so naming it in `font-family` is enough. +- **No `@page` rule** — Google Docs ignores page-setup from imported HTML. +- **`o-ld-image__caption`** (used by the Gdoc image template) is targeted in + addition to `figcaption`. + +Known caveat (tracked as Q-Gdoc-inline below): Google Docs' HTML import +honors **tag selectors** reliably but is inconsistent with **class +selectors**. So `.o-ld-material`, `.o-ld-image__caption`, etc. may not +survive into the rendered Gdoc. For class-driven styling we'd need to +inline `style="..."` directly in the `lib/doc_template/templates/gdoc/*` +renderers. + +### Q-Gdoc-inline. Inline styles for class-driven Gdoc rules +Google Docs' HTML import doesn't reliably apply class selectors. Decide: + +- (a) Accept that class-based rules (material tag italic, image caption + formatting, etc.) may not appear in the Gdoc until authors manually + re-style; rely only on tag-targeted defaults. +- (b) Update the Gdoc renderers in `lib/doc_template/templates/gdoc/` and + `lib/doc_template/tags/material_tag.rb` (and others) to emit inline + `style="font-style: italic; ..."` so the styling survives import. + +Option (b) is more reliable but adds duplication between the stylesheet +and the template emitters. + +### R5. Source-doc analysis: "Copy of LCMS Core Styling Designs Lesson Portrait" +Source: Google Doc `1xR70p8EzqKGKvjEImIKgDTKEkCdIrSJWtw7jiqzaKZk`. + +**Confirmed by inspection** (all 180 inline `font-family` declarations are +Lexend; the only colors are `#000000`, `#434343`, and 2 link colors): + +| Element | Doc value | Spec value | Match | +| --- | --- | --- | --- | +| Body | Lexend 11pt #000 lh 1.15 | same | ✅ | +| H1 | Lexend 22pt bold #000 padding-bottom 6pt | same | ✅ | +| H2 | Lexend 18pt bold #000 padding-bottom 6pt | same | ✅ | +| H3 | Lexend 14pt bold #434343 padding-bottom 4pt | same | ✅ | +| H4–H6 | not exercised in this doc | (defined in spec) | unverified | +| Material refs | italic spans | "italicized" | ✅ | +| Highlighting | none | default-off | ✅ | + +The H1–H3 numbers in `pdf.scss` / `gdoc.scss` therefore already match the +intended visual. Remaining work is structural (header banner, standards +line, tables, vocabulary line), not typographic. + +### R6. Slice plan (post-analysis decisions) + +Decisions captured from user Q&A on 2026-05-27: + +1. **Brandmark scope** → system-wide setting (admin Settings). Single + upload, reused for every lesson PDF/Gdoc. +2. **Estimated-time field** → new `estimated-time` (text) field on + lesson-metadata. Author writes free-form (e.g. "2 Class Periods"). +3. **Standards display** → only the **header strip** under H1 changes to a + bare comma-separated list. Inline `[standard: ...]` tags inside the + body keep their existing dropdown behavior. +4. **Callout shape** → keep both the existing 3-row `callout_tag` shape + (legacy) AND add a new 1-row 2-col variant (matches the design + mockup). Authors can use either. +5. **Materials toggle UI** → remove entirely. PDF/Gdoc/web all render the + materials table as a plain table without expand/collapse. +6. **Vocabulary inline** → new `vocabulary` (comma-separated text) field + on lesson-metadata for the lesson-level inline line. Activity-level + `vocabulary` field stays as-is for activity context. + +**Slices (each a separable PR):** + +- **Slice 1 — Lesson header banner.** New Settings setting for brandmark; + new `estimated-time` and `vocabulary` fields parsed off lesson-metadata; + rewrite `_header.html.erb` (PDF + Gdoc) to emit the + brandmark+lesson-type+estimated-time strip → `<hr>` → `<h1>` → bare + comma standards. New SCSS for the banner. +- **Slice 2 — Standards header rendering.** Bare comma list (no + "Standards:" prefix, no dropdown). Splits cleanly from slice 1 only if + we want to ship the banner first. +- **Slice 3 — Callout 1-row variant.** Extend `callout_tag.rb` to detect + shape and dispatch to either the legacy 3-row parser or the new 1-row + parser. New PDF + Gdoc templates for the 1-row form. New SCSS. +- **Slice 4 — Materials plain table.** Replace `materials.html.erb` with + a plain 5-row 2-col table (label / value). Remove the toggle UI and + the `o-ld-materials__toggler` JS. New SCSS rules for the table. +- **Slice 5 — Lesson-level vocabulary line.** Render the "Vocabulary: + ..." line from the new lesson-metadata field, between the Overview + block and the next H2. +- **Slice 6 — Overview block.** Render the H2 Overview + 3-bullet list + from `description-past` / `description` / `description-future` + metadata. Depends on slice 1 (header strip) being in place. +- **Slice 7 — H4/H5/H6 verification.** Get a second source doc that + actually uses these levels; visually verify the spec values render + correctly. + +**Out of scope for this initiative** (existing R1): +- PrinceXML renderer (separate plugin/PR). +- Image alt-text policy (Q4 below). +- Other spec gaps tracked under Q1, Q3, Q7. + +### R10. Slice 9 — Lesson footer (R2 design) implemented +PDF footer ([app/views/documents/pdf/_footer.erb](app/views/documents/pdf/_footer.erb)) +rewritten to the R2 layout: + +``` +© <copyright_text> +───────────────────────────────── +<Grade N/Course • Unit Title • Lesson N> <page#> (bold) +``` + +- **`copyright_text`** is a new free-form `Setting` (Documents group) — + authors type the full line, e.g. `© Acme Corp, Spring 2026`. +- **Breadcrumb** is computed by `DocumentPresenter#footer_breadcrumb`: + `Grade {N}/Course • {unit title} • Lesson {N}`. Unit title comes from + `document.resource.ancestors.find(&:unit?).title`, falling back to + `Unit {unit_id}` if the resource graph isn't populated. +- **PDF**: a new `pdf_plain.scss` (previously empty) provides Lexend + + the footer block styles. Grover renders the footer template via a + separate Chromium document, so styles must live there, not in + `pdf.scss`. +- **Gdoc**: `DocumentPresenter#gdoc_footer` still returns the original + 2-row shape (`{attribution}` placeholder + value). The value now + merges `copyright_text` with `cc_attribution` (joined by ` — `) so + the publisher copyright shows up in the Gdoc footer; the breadcrumb + line does **not** land in Gdocs yet. + + **Why not the expanded 6-row payload?** Initial implementation passed + three placeholders (`{attribution}`, `{copyright}`, `{breadcrumb}`). + Deployed to staging, this caused `DocumentGdocJob` to hang during + Apps Script post-processing — the external Apps Script function + expects the original argument arity and chokes on the extra rows, + combined with `Retriable.retriable(base_interval: 5, tries: 10)` in + `app/services/google/script_service.rb` it appears as a multi-minute + job hang. Until the Apps Script template doc in Drive is updated to + use new placeholders AND the script can accept the extra args, the + Ruby side must keep the 2-row shape. + + **Path to full Gdoc parity**: (1) update the Apps Script function + outside this repo to accept additional `[placeholder, value]` pairs + variadically and to map `{copyright}` / `{breadcrumb}` into the + footer template, (2) update the Drive template doc with the new + placeholders, (3) extend `gdoc_footer` again. Until then, treat the + Gdoc footer as a PDF-only feature. + +A new `_text.html.erb` partial under `app/views/admin/settings/show/` +backs the new `:text` Settings field type so admins can edit +`copyright_text` from `/admin/settings`. + +### R11. Slice 10 — Lesson Materials summary aggregator +`DocumentPresenter#materials_summary` walks `Document#activity_metadata` +(JSONB column populated at import time by +[lib/lt/lcms/metadata/service.rb:50](lib/lt/lcms/metadata/service.rb#L50)) +and aggregates each activity's material fields into the 5 canonical +buckets the spec mockup shows: + +| Row | Source field | +| --- | --- | +| Individual Student Materials | `activity-materials-student` | +| Pair Materials | `activity-materials-pair` | +| Small Group Materials | `activity-materials-group` | +| Class Materials | `activity-materials-class` | +| Teacher Materials | `activity-metadata-teacher` | + +Each row dedupes within its bucket and joins with `, `. Empty rows +render as **None** (matching the mockup). The whole block is skipped if +the document has no `activity_metadata` at all (e.g., during early +import or for material documents). + +Rendered above the activities by both `documents/pdf/_header.html.erb` +and `documents/gdoc/_header.html.erb`, right after the inline +Vocabulary line. SCSS adds `.c-lesson-materials__*` rules: full-width +table, 1pt cell borders, 30% left column for labels, body-text for +values. + +**Material tag tokens are resolved** (post staging QA fix): a regex pass +in `DocumentPresenter#resolve_material_tokens` strips +`[material: <id>]` brackets and, when the referenced `Material` record +exists, wraps the identifier in `<a class="o-ld-material">` (italicized +via SCSS, matching how the inline `MaterialTag` renders). Unknown +identifiers fall through to plain text without brackets. + +### R12. Slice 9/10 staging fixes +Two issues showed up after the first staging QA pass and were patched: + +1. **`DocumentGdocJob` hung at Apps Script post-processing.** The + expanded `gdoc_footer` (3 placeholders) confused the external Apps + Script function and, with `Retriable.retriable(base_interval: 5, + tries: 10)`, the job appeared stuck for minutes. Reverted + `gdoc_footer` to the original 2-row shape but merged + `copyright_text` into the `{attribution}` value so the publisher + copyright still lands in the Gdoc footer. The breadcrumb line + remains a PDF-only feature until the Apps Script template doc and + function are updated externally. + +2. **Empty bordered rectangle below the Materials block in Gdoc.** The + new activity template emitted `<hr class="o-ld-activity__divider">` + above each activity heading. Google Drive's HTML→Gdoc importer + converted that styled `<hr>` into a 1-row full-width bordered + table, producing a phantom empty rectangle. Removed the `<hr>` from + the **Gdoc** activity template; activities now flow with margin + spacing only. The PDF template keeps the `<hr>` (Chromium handles + it correctly). + +### R9. Slice 8 — Activity rendering refactored to flowing layout +The legacy `activity.html.erb` (PDF) and `gdoc/activity.html.erb` (Gdoc) +templates wrapped each `activity-metadata` block in a bordered +`<table class="o-simple-table cs-bg--math-activity-bg">` with a +subject-colored bar, an UPPERCASE kicker (`activity-type`), title row, +and a separate time cell. That table-box design came from the older +math-curriculum conventions and didn't match the spec mockup. + +Slice 8 replaces both templates with a flowing layout: + +- `<hr>` divider between activities. +- `<h3>` heading: `{activity-title} ({time} minutes)` — `Optional:` + prefix when `activity-label = optional`. +- Meta line: `{activity-type} ({student-grouping})` when present + (e.g. "Discussion (Whole Class)"). +- Materials line: `Materials: ...` aggregated from + `activity-materials` / `-student` / `-pair` / `-group` / `-class` + (comma-joined, blanks dropped). +- Metacognition / Guidance / Standards rendered as plain paragraphs. +- Body content (`@tmpl[:content]`) passes through unchanged. + +SCSS lives under `.o-ld-activity__*` in `pdf.scss` and `gdoc.scss` +(plain margins; the guidance block gets a thin left border so it still +reads as a callout-style block without a colored background). + +Side effect: this also masks the pre-existing **"type / text" +bleed-through bug** in the activity-metadata parser. The legacy template +emitted the unconsumed rows inside its `<table>`; the new template +doesn't render those raw rows at all because it only reads the parsed +attributes. The underlying parser bug in +`lib/doc_template/tables/activity.rb` is still latent and worth fixing +separately, but it no longer affects rendered output. + +**Risk**: any curriculum or plugin that relied on the legacy +`.o-ld-activity-wrapper > table.cs-bg--math-*` markup or class names +for additional styling (e.g. dese-lcms) will need to update its styles. +The data-id / data-tag / anchor / data-optional attributes are +preserved on the new wrapper div. + +### R7. Slice 4 discovery — Materials toggle was dead code, materials section is author-authored +While implementing slice 4 ("Materials plain table"), inspection revealed: + +- The `o-ld-materials__toggler` collapse/expand markup in + `lib/doc_template/templates/materials.html.erb` had **no JavaScript or + CSS** anywhere in the codebase to make it work. +- `lib/doc_template/tags/materials_tag.rb#parse` calls + `content_until_break(node)` and **discards the result** — the template + was never actually rendered. The `TEMPLATE = "materials.html.erb"` + constant was unused. +- The materials_tag spec only asserts that the section between + `[materials]` and the next stop tag is **stripped**, with no rendered + output verified. + +Conclusion: today's `[materials]` tag strips content; the "Materials" +section shown in the source-doc mockup is **hand-authored HTML in the +Google Doc** (an H2 heading + a 2-column table the author types). That +flows through the renderer unchanged. + +Slice 4 therefore reduced to dead-code cleanup: +- Deleted `lib/doc_template/templates/materials.html.erb`. +- Removed the unused `TEMPLATE` constant from `MaterialsTag`. + +### R8. Slice 3 — Callout 1-row variant authoring convention +The legacy callout shape is preserved verbatim (3 rows: marker → header → +content, rendered by `lib/doc_template/templates/callout.html.erb`). + +The **new 1-row 2-column shape** is detected automatically by row count +and dispatched to the new inline templates +(`callout_inline.html.erb` for PDF, `gdoc/callout_inline.html.erb`). +Layout: icon+label cell on the left, content cell on the right, separated +by a vertical rule. + +Authoring convention for the 1-row shape: + +| Col 1 (label) | Col 2 (content) | +| ---------------------------- | ------------------------------- | +| `[callout: <subject>]` + the visible icon + label text, each on its own paragraph in the cell | The callout body (HTML preserved) | + +The `[callout: <subject>]` marker is stripped from the rendered cell, so +the visible output is the icon + label only. The subject (e.g. `math`, +`science`, `ela`) is applied as a CSS modifier class on the wrapper for +subject-colored borders/icons. + +**Implementation**: `CalloutTag#inline_shape?` checks `tr` count; +`fetch_content` preserves col 1 `inner_html` for the inline shape so the +icon-above-label paragraphs survive into the rendered output. + +If authors prefer not to type the marker into the label cell, the +shape-detection logic can be moved up (e.g., into the Template's tag +discovery), but that's outside the scope of slice 3. + +### Q-Materials-aggregator. (New) Materials table aggregator +The spec's Materials section design implies a structured 5-row table +(Individual / Pair / Small Group / Class / Teacher). Today this requires +authors to hand-author a `<table>` in the Google Doc. The +activity-metadata schema already has the source fields: +`activity-materials-student`, `activity-materials-pair`, +`activity-materials-group`, `activity-materials-class`, and +`activity-metadata-teacher`. + +**Decision required**: should the LCMS aggregate those activity-level +fields into a per-lesson Materials table, rendering it automatically? If +so, this is a new tag/feature (a separate slice from the styling work), +not part of slice 4. + +--- + +## Open questions + +### Q1. H1 vs H2 when bundling individual assets +The spec itself raises this as open: "How to handle H1 versus H2 when bundling +individual assets? (e.g., Acknowledgements and materials that get bundled into +a single document?)" + +**Decision required**: when an asset that owns its own H1 is embedded into a +bundle that also has an H1 (front matter / acknowledgements), do we +(a) demote the asset's H1 to H2, (b) keep both H1s and rely on TOC structure, +or (c) something else? + +Affects: bundle front-matter implementation, TOC, and any heading-based +navigation in PDF/Gdoc exports. + +### Q2. Header / footer defaults for Materials and Bundles +The Lesson footer (R2) is confirmed. Still need: + +- **Header** for Lesson documents — the spec says "we will provide sensible + defaults" but does not define what they are. Provide a design or confirm + "no header" as the default. +- **Material** footer/header — the current code reuses the same footer template + as documents. Should Materials match Lesson footer (R2) or have their own + design (e.g., different metadata: Unit / Section / Material title)? +- **Bundle** footer/header — likely needs a different layout because each + embedded asset would otherwise need its own footer string. Provide design. +- **Gdoc constraint**: Google Docs cannot switch header/footer mid-document. + What's the policy when a phase or per-asset footer is desired in a Gdoc + export? Pick one footer or split into multiple Gdoc files? + +### Q3. Bundle front matter, divider pages, blank pages +The spec links a design doc ("LCMS Core Styling Designs Bundle Front Matter") +but the text in the spec does not describe the actual layout. Need: + +- Bundle front matter layout (cover-page design, fields, branding). +- Divider page layout (what triggers one, what it contains). +- "Purposefully blank" page layout (text shown, when inserted — e.g., before + duplex section start). + +These are needed before the bundle-styling phase can be built. + +### Q4. Image alt tags +Listed under Accessibility in the spec, no concrete requirement. Need: + +- Where does the alt text come from? (image `alt` attribute carried through + from the source Google Doc, a separate metadata field, or both?) +- Failure mode when alt text is missing — block export, warn, or silently + emit empty `alt=""`? +- Does this also apply to icons and decorative images, or only content + images? + +### Q5. Font delivery for Lexend +The default body font (Lexend) is not a Chromium built-in. Choose one: + +- **Self-host**: ship Lexend WOFF2 files in `vendor/` or `node_modules`, + reference via `@font-face`. Works offline and with the existing base64 + asset-embed pipeline. +- **Google Fonts CDN**: `@import` from `fonts.googleapis.com`. Simpler but + needs network at PDF-render time and may not coexist with + `ENABLE_BASE64_CACHING`. + +Same question applies to the allowlisted alternates (Arial, Nunito, Roboto, +Tahoma, Verdana) — Arial/Tahoma/Verdana are typically system fonts and may +not be present in the Chromium PDF environment. + +### Q6. Scope of the source-doc CSS normalization pass +The spec requires that exports **override** color and size from source docs, +**replace** non-allowlist font families with the body style, and **strip** +text highlighting by default. Today, `DocTemplate::Template#parse` +(`lib/doc_template/template.rb:84`) sanitizes the Google-Doc `<style>` block +and passes it through verbatim via `@document.css_styles`. + +**Decision required**: is the normalization pass part of this initiative, or +is it a separate ticket? If part of this work: + +- Implement as a post-sanitization pass over `css_styles` (regex/CSS parser), + or as an inline transformation on style attributes in the rendered body? +- Which approach is acceptable to the team given the existing sanitizer + pipeline? + +### Q7. Tag styling details — incomplete in spec +The spec lists the following tags but only Callouts and Images have full +specifications: + +- Material tag — "Normal text, but italicized" (clear) +- Icons — not specified +- Callouts — visual example given (clear enough) +- Page break — not specified (CSS `page-break-before: always`?) +- Line (horizontal rule) — not specified (weight, color, spacing?) +- Checkbox (clickable) — not specified (size, behavior in PDF vs Gdoc?) +- Video (URL) — not specified (link only? preview thumbnail? icon?) +- Heading — covered by H1–H6 defaults +- Line break — not specified (different from paragraph spacing?) +- Images — specified (clear) +- Answer (teacher version only) — not specified visually (highlighted box? + inline italic? different color?) +- Thumbnail — not specified (size, alignment, caption rules?) + +For each unspecified tag: need a visual design or "use sensible default and +review later" sign-off. + +### Q8. Initiative-level scoping +The spec text mixes engineering work with non-engineering items +(cost model, IP ownership in the "To Discuss" section). For the engineering +portion, propose splitting into the following tickets so they can be reviewed +and shipped independently: + +1. Core SCSS defaults (Lexend body, H1–H6, page setup, image caption/credit, + wire the dangling BEM classes already referenced in views). +2. Source-doc CSS normalization (Q6). +3. Tag styling pass (Q7). +4. Bundle styling — front matter, dividers, blank pages (Q3). +5. Heading-collision rule for bundling (Q1). +6. Image alt-text handling (Q4). + +Confirm whether to ship as a single PR or split per above. + +### Q9. `config/pdf.yml` `handout` profile +The spec mandates 0.5" margins as the default. The current `handout` profile +in `config/pdf.yml` uses 1.25" margins, which contradicts the spec. Is the +`handout` profile being retired, kept as a customization escape hatch, or +should it be updated to the new defaults? diff --git a/docs/pdf-generation.md b/docs/pdf-generation.md index 5fb8b99..7c466f6 100644 --- a/docs/pdf-generation.md +++ b/docs/pdf-generation.md @@ -19,8 +19,9 @@ No job code changes when a new renderer plugin is added — the seam is inside t | Identifier | Source | Strengths | Limits | |------------|---------------------------------------------------------|-------------------------------------------------------|------------------------------------------------------------------------| -| `:grover` | core, default | Modern CSS, JS execution, web fonts, fast | Cannot produce PDF/UA-1 (tagged accessible PDFs) | -| `:prince` | [`lib/plugins/prince_pdf/`](../lib/plugins/prince_pdf/) | PDF/UA-1, true paged-media typography, embedded fonts | Commercial license, controlled JS environment, separate binary install | +| `:grover` | core, default | Modern CSS, JS execution, web fonts, fast | Cannot produce PDF/UA-1 (tagged accessible PDFs) | +| `:prince` | [`lib/plugins/prince_pdf/`](../lib/plugins/prince_pdf/) | PDF/UA-1, true paged-media typography, embedded fonts | Commercial license, controlled JS environment, separate binary install | +| `:gdoc_pdf` | [`lib/plugins/gdoc_pdf/`](../lib/plugins/gdoc_pdf/) | Exact parity with the published Google Doc (Apps-Script headers/footers, Docs layout) | Requires Drive credentials, not accessible, 10 MB export ceiling, slower | Run `Exporters::Pdf::RendererRegistry.available` from the console to see what's currently usable on the host. diff --git a/lib/doc_template/objects/lesson.rb b/lib/doc_template/objects/lesson.rb index 98179d4..c70bc0b 100644 --- a/lib/doc_template/objects/lesson.rb +++ b/lib/doc_template/objects/lesson.rb @@ -7,6 +7,7 @@ class Lesson < Base attribute :description, :string, default: "" attribute :description_past, :string, default: "" attribute :description_future, :string, default: "" + attribute :estimated_time, :string, default: "" attribute :grade, :integer attribute :learning_targets, :string, default: "" attribute :lesson, :string, default: "" @@ -31,6 +32,7 @@ class Lesson < Base attribute :type, :string, default: "core" attribute :unit, :string, default: "" attribute :unit_id, :string, default: "" + attribute :vocabulary, :string, default: "" attr_accessor :lesson_prep diff --git a/lib/doc_template/tags/callout_tag.rb b/lib/doc_template/tags/callout_tag.rb index 96d4959..8677389 100644 --- a/lib/doc_template/tags/callout_tag.rb +++ b/lib/doc_template/tags/callout_tag.rb @@ -4,19 +4,34 @@ module DocTemplate module Tags class CalloutTag < TableTag TAG_NAME = "callout" + # Legacy 3-row shape: tr[1] marker, tr[2] header, tr[3] content. TEMPLATES = { default: "callout.html.erb", gdoc: "gdoc/callout.html.erb" }.freeze + # New 1-row 2-col shape: col[1] icon+label (with marker), col[2] content. + INLINE_TEMPLATES = { + default: "callout_inline.html.erb", + gdoc: "gdoc/callout_inline.html.erb" + }.freeze def parse_table(table) - header, content = fetch_content(table) + inline = inline_shape?(table) + header, content = fetch_content(table, inline:) params = { content:, header:, - subject: @opts[:metadata].subject + subject: @opts[:metadata].subject, + # Tells the inline template whether the author supplied an + # icon/label as authored HTML (1-row 2-col shape) or just a + # plain category label (3-row shapes — renderer adds a default + # decoration). + authored_label: inline } - new_content = parse_template params, template_name(@opts) + # All callouts render with the inline horizontal visual per the + # LCMS Core spec, regardless of how the author structured the + # source table. + new_content = parse_template params, inline_template_name @opts[:parent_node] = new_content parsed_content = parse_nested new_content, @opts @@ -31,9 +46,53 @@ def parse_table(table) private - def fetch_content(node) - [node.at_xpath(".//tr[2]/td").try(:content) || "", - node.at_xpath(".//tr[3]/td").try(:inner_html) || ""] + def inline_shape?(node) + node.xpath(".//tr").size == 1 + end + + def fetch_content(node, inline:) + if inline + cells = node.xpath(".//tr[1]/td") + [ + strip_tag_marker(cells[0]&.inner_html.to_s), + cells[1]&.inner_html.to_s + ] + else + # Legacy 3-row shape supports two authoring variants: + # (a) 3-row 1-col: tr[2]/td=header, tr[3]/td=content + # (b) 3-row 2-col labeled: tr[2] = "type" | <subject>, + # tr[3] = "text" | <body> + # Prefer td[2] (value column) when present; fall back to td[1]. + [ + value_cell_content(node, 2), + value_cell_inner_html(node, 3) + ] + end + end + + def value_cell_content(node, row_index) + row = node.at_xpath(".//tr[#{row_index}]") + return "" unless row + + value = row.at_xpath("./td[2]").try(:content).to_s.squish + value.presence || row.at_xpath("./td[1]").try(:content).to_s + end + + def value_cell_inner_html(node, row_index) + row = node.at_xpath(".//tr[#{row_index}]") + return "" unless row + + value = row.at_xpath("./td[2]").try(:inner_html).to_s + value.strip.presence || row.at_xpath("./td[1]").try(:inner_html).to_s + end + + def strip_tag_marker(html) + html.gsub(/\[\s*#{Regexp.escape(self.class::TAG_NAME)}[^\]]*\]/i, "") + end + + def inline_template_name + context = @opts.fetch(:context_type, :default).to_s + INLINE_TEMPLATES[context.to_sym] end def previous_non_empty(node) diff --git a/lib/doc_template/tags/helpers.rb b/lib/doc_template/tags/helpers.rb index 57cc4d3..80a6f18 100644 --- a/lib/doc_template/tags/helpers.rb +++ b/lib/doc_template/tags/helpers.rb @@ -18,6 +18,25 @@ def priority_description(activity) config = Tags.config[self.class::TAG_NAME.downcase] Array.wrap(config["priority_descriptions"])[priority - 1] end + + # Replaces `[material: id]` tokens in plain text with the italicized + # identifier markup that MaterialTag emits inline. Used in the + # activity Materials line so authored tokens render the same as in + # the body. Unknown identifiers fall through to bare identifier text. + MATERIAL_TOKEN_RE = /\[material:\s*([^\]]+)\]/i + + def resolve_material_tokens(text) + text.to_s.gsub(MATERIAL_TOKEN_RE) do + identifier = ::Regexp.last_match(1).to_s.strip + next identifier if identifier.blank? + + if ::Material.exists?(identifier: identifier.downcase) + %(<a class="o-ld-material">#{identifier}</a>) + else + identifier + end + end + end end end end diff --git a/lib/doc_template/tags/materials_tag.rb b/lib/doc_template/tags/materials_tag.rb index ef53c1c..7f9642d 100644 --- a/lib/doc_template/tags/materials_tag.rb +++ b/lib/doc_template/tags/materials_tag.rb @@ -4,7 +4,6 @@ module DocTemplate module Tags class MaterialsTag < BaseTag TAG_NAME = "materials" - TEMPLATE = "materials.html.erb" def parse(node, _opts = {}) # we have to collect all the next siblings until next activity-metadata diff --git a/lib/doc_template/templates/activity.html.erb b/lib/doc_template/templates/activity.html.erb index efa11b3..9b0ed64 100644 --- a/lib/doc_template/templates/activity.html.erb +++ b/lib/doc_template/templates/activity.html.erb @@ -1,64 +1,55 @@ -<div class="o-ld-activity-wrapper" - data-id="<%= @tmpl[:activity].anchor %>" +<% + activity = @tmpl[:activity] + type_label = activity.activity_type.to_s.strip + group_label = activity.student_grouping.to_s.strip + materials_list = [ + activity.activity_materials, + activity.activity_materials_student, + activity.activity_materials_pair, + activity.activity_materials_group, + activity.activity_materials_class + ].compact.map(&:to_s).reject(&:blank?).join(", ") +%> +<div class="o-ld-activity" + id="<%= activity.anchor %>" + data-id="<%= activity.anchor %>" data-tag="<%= @tmpl[:placeholder] %>" - <%= 'data-optional' if @tmpl[:activity].optional %>> - <hr class='o-ld-hr--l2'> + <%= "data-optional" if activity.optional %>> - <% if @tmpl[:activity].alert.present? %> - <div class="o-ld-activity__optional text-center"> - <%= @tmpl[:activity].alert %> - </div> + <hr class="o-ld-activity__divider"> + + <% if activity.alert.present? %> + <p class="o-ld-activity__alert"><%= activity.alert %></p> <% end %> - <div id="<%= @tmpl[:activity].anchor %>" class="o-ld-activity c-ld-toc"> - <div> - <div class="u-pdf-nobreak o-ld-activity__title u-text--uppercase cs-txt--math-base u-txt--ld-activity-kicker u-margin-bottom--xs"> - <%= @tmpl[:activity].activity_type %> - </div> - <h3 class="o-ld-title"> - <div class="o-ld-title__title o-ld-title__title--h3"> - <%= 'Optional: ' if @tmpl[:activity].optional %> - <%= @tmpl[:activity].activity_title %> - <% if @tmpl[:activity].activity_priority.present? %> - <% priority_id = "o-ld-p_#{SecureRandom.hex(4)}" %> - <div class="o-ld-icon__wrapper" data-toggle="<%= priority_id %>"> - <span class="o-ld-icon o-ld-icon--base o-ld-icon--priority<%= @tmpl[:activity].activity_priority %>"></span> - </div> - <span class="dropdown-pane o-ld-dropdown bottom" - data-dropdown - data-hover="true" - data-hover-delay="0" - data-hover-pane="true" - data-v-offset="8" - id="<%= priority_id %>"> - <%= @tmpl[:priority_description] %> - </span> - <% end %> - </div> - <div class="o-ld-title__time o-ld-title__time--h3"><%= @tmpl[:activity].time.zero? ? '—' : "#{@tmpl[:activity].time} mins" %></div> - </h3> + <h3 class="o-ld-activity__heading"> + <%= "Optional: " if activity.optional %><%= activity.activity_title %><% if activity.time.to_i.positive? %> (<%= activity.time %> minutes)<% end %> + </h3> + + <% if type_label.present? || group_label.present? %> + <p class="o-ld-activity__meta"> + <%= type_label %><% if type_label.present? && group_label.present? %> <% end %><% if group_label.present? %>(<%= group_label %>)<% end %> + </p> + <% end %> - <% if @tmpl[:activity].activity_metacognition.present? %> - <div class="o-ld-activity__metacognition u-txt--ld-activity-teaser u-margin-bottom--base"> - <%= @tmpl[:activity].activity_metacognition %> - </div> - <% end %> + <% if materials_list.present? %> + <p class="o-ld-activity__materials">Materials: <%= raw resolve_material_tokens(materials_list) %></p> + <% end %> - <% if @tmpl[:activity].activity_guidance.present? %> - <div class="o-ld-activity__guidance"> - <strong class="o-ld-activity__guidance--title u-text--uppercase cs-txt--math-base u-txt--title-type">Guidance</strong> - <%= @tmpl[:activity].activity_guidance %> - </div> - <% end %> + <% if activity.activity_metacognition.present? %> + <p class="o-ld-activity__metacognition"><%= raw activity.activity_metacognition %></p> + <% end %> - <% if @tmpl[:activity].activity_standard.present? %> - <p> - <strong>Standards: </strong> - <span><%= @tmpl[:activity].activity_standard %></span> - </p> - <% end %> + <% if activity.activity_guidance.present? %> + <div class="o-ld-activity__guidance"> + <p><strong>Guidance:</strong></p> + <%= raw activity.activity_guidance %> </div> - </div> + <% end %> + + <% if activity.activity_standard.present? %> + <p class="o-ld-activity__standard"><strong>Standards:</strong> <%= activity.activity_standard %></p> + <% end %> <%= @tmpl[:content] %> diff --git a/lib/doc_template/templates/callout_inline.html.erb b/lib/doc_template/templates/callout_inline.html.erb new file mode 100644 index 0000000..5005733 --- /dev/null +++ b/lib/doc_template/templates/callout_inline.html.erb @@ -0,0 +1,17 @@ +<div class="o-ld-callout o-ld-callout--inline o-ld-callout--<%= @tmpl[:subject] %>"> + <table class="o-ld-callout__layout"> + <tr> + <td class="o-ld-callout__label"> + <% if @tmpl[:authored_label] %> + <%= @tmpl[:header].html_safe %> + <% else %> + <div class="o-ld-callout__icon">+</div> + <div class="o-ld-callout__type"><%= @tmpl[:header].html_safe %></div> + <% end %> + </td> + <td class="o-ld-callout__body"> + <%= @tmpl[:content].html_safe %> + </td> + </tr> + </table> +</div> diff --git a/lib/doc_template/templates/gdoc/activity.html.erb b/lib/doc_template/templates/gdoc/activity.html.erb index bc4aa91..b0c518a 100644 --- a/lib/doc_template/templates/gdoc/activity.html.erb +++ b/lib/doc_template/templates/gdoc/activity.html.erb @@ -1,68 +1,49 @@ -<div class="o-ld-activity-wrapper" data-id="<%= @tmpl[:activity].anchor %>" data-tag="<%= @tmpl[:placeholder] %>"> - <div class="o-ld-hr--l2"> - <hr> - </div> +<% + activity = @tmpl[:activity] + type_label = activity.activity_type.to_s.strip + group_label = activity.student_grouping.to_s.strip + materials_list = [ + activity.activity_materials, + activity.activity_materials_student, + activity.activity_materials_pair, + activity.activity_materials_group, + activity.activity_materials_class + ].compact.map(&:to_s).reject(&:blank?).join(", ") +%> +<div class="o-ld-activity" id="<%= activity.anchor %>" data-id="<%= activity.anchor %>" data-tag="<%= @tmpl[:placeholder] %>"> - <% if @tmpl[:activity].alert.present? %> - <table class="o-ld-activity__optional text-center o-simple-table"> - <tr> - <td><%= @tmpl[:activity].alert %></td> - </tr> - </table> + <% if activity.alert.present? %> + <p class="o-ld-activity__alert"><%= activity.alert %></p> <% end %> - <table id="<%= @tmpl[:activity].anchor %>" class="o-simple-table cs-bg--math-activity-bg %>"> - <tr> - <td class="u-table-wrap--l2 cs-border-top--math"> - <table class="o-simple-table u-pdf-nobreak u-pdf-nobreak--around"> - <% if @tmpl[:activity].activity_type.present? %> - <tr> - <td colspan="2" class="cs-txt--math-base u-txt--title-type"> - <%= @tmpl[:activity].activity_type.upcase %> - </td> - </tr> - <% end %> - <tr> - <td class="o-ld-title__title"> - <h3 class="o-ld-title__title--h3"> - <%= 'Optional: ' if @tmpl[:activity].optional %> - <%= @tmpl[:activity].activity_title %> - </h3> - </td> - <td class="o-ld-title__time o-ld-title__time--h3 u-text--right"><%= @tmpl[:activity].time.zero? ? '—' : "#{@tmpl[:activity].time} mins" %></td> - </tr> - </table> + <h3 class="o-ld-activity__heading" style="font-family:'Lexend',Arial,sans-serif;font-size:14pt;font-weight:bold;color:#434343;line-height:1.15;margin:0 0 4pt 0;padding:0;"> + <%= "Optional: " if activity.optional %><%= activity.activity_title %><% if activity.time.to_i.positive? %> (<%= activity.time %> minutes)<% end %> + </h3> - <% if @tmpl[:activity].activity_metacognition.present? %> - <span class="u-txt--ld-activity-teaser"> - <%= @tmpl[:activity].activity_metacognition %> - </span> - <% end %> + <% if type_label.present? || group_label.present? %> + <p class="o-ld-activity__meta"> + <%= type_label %><% if type_label.present? && group_label.present? %> <% end %><% if group_label.present? %>(<%= group_label %>)<% end %> + </p> + <% end %> + + <% if materials_list.present? %> + <p class="o-ld-activity__materials">Materials: <%= raw resolve_material_tokens(materials_list) %></p> + <% end %> - <% if @tmpl[:activity].activity_guidance.present? %> - <table class="o-simple-table cs-bg--math-activity-guidance-bg"> - <tr> - <td class="u-table-wrap"> - <p class="cs-txt--math-base u-txt--title-type u-padding-bottom--xs"> - <strong>GUIDANCE</strong> - </p> - <%= @tmpl[:activity].activity_guidance %> - </td> - </tr> - </table> - <p class="do-not-strip"></p> - <% end %> + <% if activity.activity_metacognition.present? %> + <p class="o-ld-activity__metacognition"><%= raw activity.activity_metacognition %></p> + <% end %> - <% if @tmpl[:activity].activity_standard.present? %> - <p> - <strong>Standard: </strong> - <span><%= @tmpl[:activity].activity_standard %></span> - </p> - <% end %> + <% if activity.activity_guidance.present? %> + <div class="o-ld-activity__guidance"> + <p><strong>Guidance:</strong></p> + <%= raw activity.activity_guidance %> + </div> + <% end %> - <%= @tmpl[:content] %> + <% if activity.activity_standard.present? %> + <p class="o-ld-activity__standard"><strong>Standards:</strong> <%= activity.activity_standard %></p> + <% end %> - </td> - </tr> - </table> + <%= @tmpl[:content] %> </div> diff --git a/lib/doc_template/templates/gdoc/callout_inline.html.erb b/lib/doc_template/templates/gdoc/callout_inline.html.erb new file mode 100644 index 0000000..a5e07a7 --- /dev/null +++ b/lib/doc_template/templates/gdoc/callout_inline.html.erb @@ -0,0 +1,18 @@ +<div class="o-ld-callout o-ld-callout--inline"> + <table class="o-simple-table o-ld-callout__layout"> + <tr> + <td class="o-ld-callout__label cs-border-left--<%= @tmpl[:subject] %>"> + <% if @tmpl[:authored_label] %> + <%= @tmpl[:header].html_safe %> + <% else %> + <div class="o-ld-callout__icon">+</div> + <div class="o-ld-callout__type"><%= @tmpl[:header].html_safe %></div> + <% end %> + </td> + <td class="o-ld-callout__body o-ld-callout__content--<%= @tmpl[:subject] %>"> + <%= @tmpl[:content].html_safe %> + </td> + </tr> + </table> + <p class="do-not-strip"></p> +</div> diff --git a/lib/doc_template/templates/materials.html.erb b/lib/doc_template/templates/materials.html.erb deleted file mode 100644 index fc9a0b2..0000000 --- a/lib/doc_template/templates/materials.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<div class="o-ld-materials"> - <div class="o-ld-materials__content--hidden"><%= @tmpl %></div> - <div class="o-ld-materials__toggler"> - <span class="o-ld-materials__toggler--hide"><strong>Collapse Materials</strong></span> - <span class="o-ld-materials__toggler--show"><strong>Expand Materials</strong></span> - </div> -</div> diff --git a/lib/exporters/gdoc/base.rb b/lib/exporters/gdoc/base.rb index fd763f6..5ef9d19 100644 --- a/lib/exporters/gdoc/base.rb +++ b/lib/exporters/gdoc/base.rb @@ -25,6 +25,15 @@ class << self def url_for(file_id) "https://drive.google.com/open?id=#{file_id}" end + + # Inverse of url_for: extracts the Drive file id from a Google Doc URL. + # Handles the canonical `?id=<id>` form produced by url_for and the + # `/d/<id>/` form Drive/Docs links use. Returns nil for blank input. + def file_id_from(url) + return if url.blank? + + url[/[?&]id=([^&#]+)/, 1] || url[%r{/d/([^/?#]+)}, 1] + end end def create_gdoc_folders(folder) @@ -134,7 +143,7 @@ def gdoc_folder def gdoc_folder_tmp(material_ids) file_ids = material_ids.map do |id| - document.links["materials"]&.dig(id.to_s, "gdoc")&.gsub(/.*id=/, "") + self.class.file_id_from(document.links["materials"]&.dig(id.to_s, "gdoc")) end @options[:subfolders] = [self.class::FOLDER_NAME] diff --git a/lib/exporters/pdf/base.rb b/lib/exporters/pdf/base.rb index f3f4e34..9723c82 100644 --- a/lib/exporters/pdf/base.rb +++ b/lib/exporters/pdf/base.rb @@ -26,6 +26,11 @@ module Pdf # — core models do not define these methods; # a plugin opts in by extending Document/Material # with accessors that read from metadata jsonb. + # (c) record handle: build_render_options threads the presenter into + # RenderOptions#source so a renderer that needs the + # record itself (not just rendered HTML) can reach it + # — e.g. the gdoc_pdf plugin, which exports the + # record's Google Doc to PDF rather than rendering HTML. # # Resolution chain (preserved in #renderer_name / #accessibility_level): # per-call option -> per-record method -> project default @@ -65,7 +70,8 @@ def accessibility_level def build_render_options @document.render_options.with( accessibility: accessibility_level, - footer_html: render_template(base_path("_footer"), layout: "pdf_plain") + footer_html: render_template(base_path("_footer"), layout: "pdf_plain"), + source: @document ) end diff --git a/lib/exporters/pdf/render_options.rb b/lib/exporters/pdf/render_options.rb index 85b3282..093cef1 100644 --- a/lib/exporters/pdf/render_options.rb +++ b/lib/exporters/pdf/render_options.rb @@ -14,6 +14,13 @@ module Pdf # Construct via `RenderOptions.build(...)` to pick up defaults. # Direct `RenderOptions.new(...)` is supported but requires every field. # + # `source` is a Tier-2 programmatic seam (see Exporters::Pdf::Base): the + # exporter threads the document/material presenter through here so renderers + # that need the record itself — not just its rendered HTML — can reach it. + # Most renderers ignore it. Unlike `extra` (which renderers may spread into + # their engine options, e.g. Grover), `source` is a dedicated field so it + # never leaks into an engine's option hash. It is an in-process handle and + # is never serialized. RenderOptions = Data.define( :format, :orientation, @@ -28,7 +35,8 @@ module Pdf :show_header, :show_name_date, :padding, - :extra + :extra, + :source ) class RenderOptions @@ -49,7 +57,8 @@ class RenderOptions show_header: true, show_name_date: false, padding: nil, - extra: {}.freeze + extra: {}.freeze, + source: nil }.freeze def self.build(**overrides) diff --git a/lib/plugins/gdoc_pdf/Gemfile b/lib/plugins/gdoc_pdf/Gemfile new file mode 100644 index 0000000..d83814a --- /dev/null +++ b/lib/plugins/gdoc_pdf/Gemfile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# GdocPdf plugin dependencies. +# Auto-loaded by the main Gemfile via eval_gemfile. +# +# This plugin has no dependencies of its own. It reuses gems already +# present in the host application's Gemfile: +# - google-apis-drive_v3 (Drive files.export) +# - lt-google-api (credentials + Drive service wrapper) +# - retriable (rate-limit backoff) +# +# See README.md for configuration and runtime requirements. diff --git a/lib/plugins/gdoc_pdf/README.md b/lib/plugins/gdoc_pdf/README.md new file mode 100644 index 0000000..12d7142 --- /dev/null +++ b/lib/plugins/gdoc_pdf/README.md @@ -0,0 +1,95 @@ +# GdocPdf + +PDF rendering via Google Doc export for LCMS Core. Plugs into +`Exporters::Pdf::RendererRegistry` as `:gdoc_pdf`. + +Instead of rendering HTML through a print engine (like the default `:grover` +or the accessible `:prince` renderer), this renderer exports the record's +**actual generated Google Doc** to PDF via the Drive `files.export` endpoint. +The output is identical in styling to the published Google Doc — the same +Apps-Script-applied headers/footers and the same Docs layout engine — which +HTML-based renderers cannot reproduce exactly. + +This plugin ships in-tree under `lib/plugins/gdoc_pdf/`. + +## When to use it + +Use the `:gdoc_pdf` renderer when the PDF **must match the Google Doc +exactly** — i.e. the Google Doc is the canonical deliverable and the PDF is +just a download of it. + +Stick with `:grover` (default) or `:prince` when: + +- You need **PDF/UA-1 accessibility** — Google Docs export is not certifiable + (`:gdoc_pdf` advertises no accessibility capability, so the registry + refuses `:gdoc_pdf` + `:tagged` / `:pdf_ua` requests). +- You need **print-engine fidelity** to the web/HTML rendering rather than to + the Google Doc. +- The document has **no Google Doc** and you don't want the render to create + one (this renderer will generate one on demand — see below). + +## How it works + +1. **Reuse** — if the record already has a Google Doc link in + `links[content_type]["gdoc"]`, that doc is exported as-is. There is **no + staleness check**: the PDF faithfully mirrors the published Google Doc, + even if the doc predates the latest content edits. +2. **Generate (ephemeral)** — if no Google Doc link exists, the full Gdoc + pipeline runs (`Exporters::Gdoc::Document` / `Material`, including the + `Google::ScriptService` Apps Script post-processing), then the resulting + doc is exported. The generated doc is **not** written back into the + record's `links` — generation is a side effect of the render, not a change + to the Google Doc lifecycle. The pipeline updates an existing same-named + doc in place rather than creating duplicates, so repeated renders are + idempotent. + +The record reaches the renderer through `RenderOptions#source`, which +`Exporters::Pdf::Base` threads in. (HTML-based renderers ignore it.) + +## Selecting the renderer + +Selection uses the standard chain (per-call option → per-record metadata → +project default): + +```ruby +# Per call +DocumentPdfJob.perform_later(doc.id, content_type: :unit_bundle, renderer: :gdoc_pdf) + +# Per record (requires a plugin that exposes #pdf_renderer from metadata) +doc.update!(metadata: doc.metadata.merge("pdf_renderer" => "gdoc_pdf")) + +# Project default +# DEFAULT_PDF_RENDERER=gdoc_pdf (env), or the pdf.default_renderer admin Setting +``` + +## Runtime requirements + +- **Google Drive credentials** — the same file-based service-account + credentials used by the Gdoc pipeline, resolved via + `Lt::Google::Api::Auth::Cli`. When credentials cannot be resolved, + `Renderer.available?` returns `false` and the registry filters `:gdoc_pdf` + out of `.available`; records requesting it then fail fast. +- The same `GOOGLE_APPLICATION_*` environment variables the Gdoc pipeline + relies on (folder ID, Apps Script ID/function, portrait/landscape template + IDs) — only needed for the *generate* path. + +## Limitations & risks + +- **10 MB export ceiling** — Drive's `files.export` rejects results larger + than 10 MB. Large unit bundles may exceed it and raise `ExportError`. +- **Not accessible** — output is not PDF/UA-1. +- **Latency** — the reuse path is a single Drive export call (fast); the + generate path performs an HTML→Docs upload, an Apps Script round-trip, and + the export, so it is markedly slower and subject to Drive rate limits + (retried with backoff via `retriable`). +- **Wasted HTML render** — `Exporters::Pdf::Base` always renders the PDF + template HTML before invoking the renderer; `:gdoc_pdf` discards it. This is + an accepted cost of fitting a Google-Doc-export operation into the + HTML-in / bytes-out renderer contract. + +## Tuning (environment variables) + +| Variable | Default | Purpose | +|---|---|---| +| `GDOC_PDF_EXPORT_TRIES` | `5` | Retries for rate-limited / transient Drive errors during export | +| `GDOC_PDF_EXPORT_BASE_INTERVAL` | `5` | Base backoff interval (seconds) between export retries | diff --git a/lib/plugins/gdoc_pdf/lib/gdoc_pdf.rb b/lib/plugins/gdoc_pdf/lib/gdoc_pdf.rb new file mode 100644 index 0000000..8d4d568 --- /dev/null +++ b/lib/plugins/gdoc_pdf/lib/gdoc_pdf.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# GdocPdf — PDF rendering via Google Doc export. +# +# Plugs into Exporters::Pdf::RendererRegistry as `:gdoc_pdf`. Instead of +# rendering HTML through a print engine, this renderer exports the record's +# *actual* generated Google Doc to PDF via the Drive `files.export` endpoint. +# The result is byte-for-byte identical in styling to the published Google +# Doc — same Apps-Script-applied headers/footers and Docs layout — which the +# HTML-based :grover and :prince renderers cannot guarantee. See ADR-0001. +# +# Because it needs the record (not just rendered HTML), it reads the +# presenter from RenderOptions#source, threaded in by Exporters::Pdf::Base. +# +# Runtime requirements: +# - The same Google Drive service-account credentials used by the Gdoc +# pipeline (file-based credentials resolved via Lt::Google::Api::Auth::Cli) +# - The Drive export size ceiling is 10 MB per file +# +# See README.md for configuration and behavior details. +# +module GdocPdf + class << self + def setup! + ::Exporters::Pdf::RendererRegistry.register(Renderer) + PluginSystem.logger.info \ + "[GdocPdf] :gdoc_pdf renderer registered (available=#{Renderer.available?})" + end + end +end diff --git a/lib/plugins/gdoc_pdf/lib/gdoc_pdf/exporter.rb b/lib/plugins/gdoc_pdf/lib/gdoc_pdf/exporter.rb new file mode 100644 index 0000000..862678f --- /dev/null +++ b/lib/plugins/gdoc_pdf/lib/gdoc_pdf/exporter.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "stringio" +require "google/apis/drive_v3" + +module GdocPdf + # + # Resolves (or generates) the record's Google Doc and exports it to PDF. + # + # Source resolution: + # 1. Reuse — if the record already has a Google Doc link in + # `links[content_type]["gdoc"]`, export that doc as-is. No staleness + # check: the PDF faithfully mirrors the published Google Doc. + # 2. Generate (ephemeral) — otherwise run the full Gdoc pipeline + # (Exporters::Gdoc::Document / Material, including Google::ScriptService + # post-processing) to produce the doc, then export it. The generated + # doc is NOT written back into the record's `links` — generation is a + # side effect of this render, not a change to the gdoc lifecycle. + # (The Gdoc pipeline updates an existing same-named doc in place rather + # than creating duplicates, so repeated renders stay idempotent.) + # + # Export uses Drive `files.export` with mimeType application/pdf. Drive caps + # exported files at 10 MB; larger documents raise ExportError. + # + class Exporter + class ExportError < StandardError; end + + PDF_MIME = "application/pdf" + GDOC_LINK_KEY = "gdoc" + RATE_RETRIABLE_ERRORS = ::Exporters::Gdoc::Base::GOOGLE_API_RATE_RETRIABLE_ERRORS + EXPORT_TRIES = ENV.fetch("GDOC_PDF_EXPORT_TRIES", 5).to_i + EXPORT_BASE_INTERVAL = ENV.fetch("GDOC_PDF_EXPORT_BASE_INTERVAL", 5).to_i + + class << self + # True when Google Drive credentials can be resolved. Used by the + # renderer's `.available?` so the registry can filter :gdoc_pdf out + # when credentials are absent. Non-raising by design. + def credentials_present? + ::Lt::Google::Api::Auth::Cli.new.credentials.present? + rescue StandardError + false + end + end + + # @param source [DocumentPresenter, MaterialPresenter] the record presenter + # threaded in via RenderOptions#source + # @param render_options [Exporters::Pdf::RenderOptions, nil] the render + # options (currently unused by the export path; accepted for symmetry + # and future per-call overrides) + def initialize(source, render_options = nil) + @source = source + @render_options = render_options + end + + # @return [String] the exported PDF bytes + def to_pdf + export_to_pdf(resolve_file_id) + rescue ::Google::Apis::Error => e + raise ExportError, e.message + end + + private + + attr_reader :source + + def resolve_file_id + reuse_file_id || generate_file_id + end + + # Step 1 — reuse an already-generated Google Doc, if one is linked. + def reuse_file_id + entry = source.links&.dig(source.content_type.to_s, GDOC_LINK_KEY) + url = entry.is_a?(Hash) ? entry["url"] : entry + extract_file_id(url) + end + + # Step 2 — generate the Google Doc through the full Gdoc pipeline. + def generate_file_id + gdoc = gdoc_exporter_class.new(source, {}).export + extract_file_id(gdoc.url) + end + + def gdoc_exporter_class + source.is_a?(MaterialPresenter) ? ::Exporters::Gdoc::Material : ::Exporters::Gdoc::Document + end + + def export_to_pdf(file_id) + raise ExportError, "no Google Doc available to export for #{source.class}" if file_id.blank? + + # A fresh StringIO per attempt: Drive's download streams bytes into the + # supplied IO without truncating it, so reusing one buffer across retries + # would append a second copy after a partial write and corrupt the PDF. + Retriable.retriable(on: RATE_RETRIABLE_ERRORS, tries: EXPORT_TRIES, base_interval: EXPORT_BASE_INTERVAL) do + io = StringIO.new.tap { |s| s.set_encoding(Encoding::BINARY) } + drive.service.export_file(file_id, PDF_MIME, download_dest: io) + io.string + end + end + + # Google Doc URLs are stored as `https://drive.google.com/open?id=<id>` + # (see Exporters::Gdoc::Base.url_for). Delegate to the canonical inverse so + # the URL schema lives in one place and the `/d/<id>` form is tolerated. + def extract_file_id(url) + ::Exporters::Gdoc::Base.file_id_from(url) + end + + def drive + @drive ||= ::Google::DriveService.build(source, {}) + end + end +end diff --git a/lib/plugins/gdoc_pdf/lib/gdoc_pdf/renderer.rb b/lib/plugins/gdoc_pdf/lib/gdoc_pdf/renderer.rb new file mode 100644 index 0000000..c65d37f --- /dev/null +++ b/lib/plugins/gdoc_pdf/lib/gdoc_pdf/renderer.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module GdocPdf + # + # PDF renderer that exports the record's Google Doc to PDF. + # + # Implements Exporters::Pdf::Renderers::Base. Unlike HTML-based renderers, + # `#call` ignores the rendered HTML it is handed: the whole point is to + # export the *real* Google Doc (with its Apps-Script-applied headers/footers + # and Docs layout), so the rendered HTML is the wrong artifact. The record + # itself arrives via `options.source`, threaded in by Exporters::Pdf::Base. + # + # Capabilities are intentionally empty: Google Docs' PDF export is not + # PDF/UA-1 certifiable, so the renderer advertises no accessibility + # capability and the registry refuses :gdoc_pdf + :tagged / :pdf_ua + # requests (fail-fast rather than silently emitting a non-accessible PDF). + # + # Available? returns false when Drive credentials cannot be resolved, so + # the registry filters :gdoc_pdf out of `.available` and records requesting + # it fail fast rather than silently downgrading. + # + class Renderer < ::Exporters::Pdf::Renderers::Base + def self.identifier = :gdoc_pdf + + def self.available? + Exporter.credentials_present? + end + + def call(_html, options:) + source = options.source + if source.nil? + raise ::Exporters::Pdf::RendererRegistry::RenderError, + "gdoc_pdf renderer requires options.source (the document/material " \ + "presenter). It is normally selected through Exporters::Pdf::Base, " \ + "which threads the record; direct invocation without a source is " \ + "not supported." + end + + Exporter.new(source, options).to_pdf + rescue Exporter::ExportError => e + raise ::Exporters::Pdf::RendererRegistry::RenderError, "Gdoc PDF export failed: #{e.message}" + end + end +end diff --git a/lib/plugins/gdoc_pdf/sig/gdoc_pdf.rbs b/lib/plugins/gdoc_pdf/sig/gdoc_pdf.rbs new file mode 100644 index 0000000..98dd717 --- /dev/null +++ b/lib/plugins/gdoc_pdf/sig/gdoc_pdf.rbs @@ -0,0 +1,3 @@ +module GdocPdf + def self.setup!: () -> void +end diff --git a/lib/plugins/gdoc_pdf/sig/gdoc_pdf/exporter.rbs b/lib/plugins/gdoc_pdf/sig/gdoc_pdf/exporter.rbs new file mode 100644 index 0000000..8841c29 --- /dev/null +++ b/lib/plugins/gdoc_pdf/sig/gdoc_pdf/exporter.rbs @@ -0,0 +1,30 @@ +module GdocPdf + class Exporter + class ExportError < StandardError + end + + PDF_MIME: String + GDOC_LINK_KEY: String + RATE_RETRIABLE_ERRORS: Array[Class] + EXPORT_TRIES: Integer + EXPORT_BASE_INTERVAL: Integer + + def self.credentials_present?: () -> bool + + def initialize: (untyped source, ?::Exporters::Pdf::RenderOptions? render_options) -> void + + def to_pdf: () -> String + + private + + attr_reader source: untyped + + def resolve_file_id: () -> String? + def reuse_file_id: () -> String? + def generate_file_id: () -> String? + def gdoc_exporter_class: () -> untyped + def export_to_pdf: (String? file_id) -> String + def extract_file_id: (String? url) -> String? + def drive: () -> untyped + end +end diff --git a/lib/plugins/gdoc_pdf/sig/gdoc_pdf/renderer.rbs b/lib/plugins/gdoc_pdf/sig/gdoc_pdf/renderer.rbs new file mode 100644 index 0000000..8db50d7 --- /dev/null +++ b/lib/plugins/gdoc_pdf/sig/gdoc_pdf/renderer.rbs @@ -0,0 +1,8 @@ +module GdocPdf + class Renderer < ::Exporters::Pdf::Renderers::Base + def self.identifier: () -> Symbol + def self.available?: () -> bool + + def call: (String? html, options: ::Exporters::Pdf::RenderOptions) -> String + end +end diff --git a/lib/plugins/gdoc_pdf/spec/gdoc_pdf/exporter_spec.rb b/lib/plugins/gdoc_pdf/spec/gdoc_pdf/exporter_spec.rb new file mode 100644 index 0000000..49aa782 --- /dev/null +++ b/lib/plugins/gdoc_pdf/spec/gdoc_pdf/exporter_spec.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe GdocPdf::Exporter do + subject(:exporter) { described_class.new(source) } + + let(:fake_pdf) { "%PDF-1.4 fake bytes" } + let(:raw_service) { instance_double(Google::Apis::DriveV3::DriveService) } + let(:drive) { instance_double(Google::DriveService, service: raw_service) } + + # Drive's export_file streams bytes into the supplied download_dest IO. + def stub_export(returning: fake_pdf) + allow(raw_service).to receive(:export_file) do |_file_id, _mime, download_dest:| + download_dest.write(returning) + end + end + + before { allow(Google::DriveService).to receive(:build).and_return(drive) } + + describe ".credentials_present?" do + it "is true when credentials resolve" do + cli = instance_double(Lt::Google::Api::Auth::Cli, credentials: Object.new) + allow(Lt::Google::Api::Auth::Cli).to receive(:new).and_return(cli) + expect(described_class.credentials_present?).to be true + end + + it "is false when credential resolution raises" do + allow(Lt::Google::Api::Auth::Cli).to receive(:new).and_raise(StandardError, "no creds") + expect(described_class.credentials_present?).to be false + end + end + + describe "#to_pdf — reuse path" do + let(:source) do + double( + "DocumentPresenter", + content_type: :unit_bundle, + links: { "unit_bundle" => { "gdoc" => { "url" => "https://drive.google.com/open?id=REUSED123" } } } + ) + end + + it "exports the already-linked Google Doc to PDF and returns the bytes" do + stub_export + expect(exporter.to_pdf).to eq(fake_pdf) + expect(raw_service).to have_received(:export_file) + .with("REUSED123", "application/pdf", download_dest: instance_of(StringIO)) + end + + it "does not generate a new Google Doc when one is already linked" do + stub_export + expect(Exporters::Gdoc::Document).not_to receive(:new) + exporter.to_pdf + end + end + + describe "#to_pdf — generate path" do + let(:source) do + double("DocumentPresenter", content_type: :unit_bundle, links: {}) + end + let(:gdoc) { instance_double(Exporters::Gdoc::Document) } + + before do + allow(Exporters::Gdoc::Document).to receive(:new).with(source, {}).and_return(gdoc) + allow(gdoc).to receive(:export).and_return(gdoc) + allow(gdoc).to receive(:url).and_return("https://drive.google.com/open?id=GENERATED456") + end + + it "generates the Google Doc through the full pipeline, then exports it" do + stub_export + expect(exporter.to_pdf).to eq(fake_pdf) + expect(Exporters::Gdoc::Document).to have_received(:new).with(source, {}) + expect(raw_service).to have_received(:export_file) + .with("GENERATED456", "application/pdf", download_dest: instance_of(StringIO)) + end + + it "raises ExportError when no Google Doc can be resolved" do + allow(gdoc).to receive(:url).and_return("") + expect { exporter.to_pdf }.to raise_error(described_class::ExportError, /no Google Doc available/) + end + end + + describe "#to_pdf — generate path (Material)" do + let(:material) { double("Material", links: {}) } + let(:source) { MaterialPresenter.new(material, content_type: :unit_bundle) } + let(:gdoc) { instance_double(Exporters::Gdoc::Material) } + + before do + allow(Exporters::Gdoc::Document).to receive(:new) + allow(Exporters::Gdoc::Material).to receive(:new).and_return(gdoc) + allow(gdoc).to receive(:export).and_return(gdoc) + allow(gdoc).to receive(:url).and_return("https://drive.google.com/open?id=MAT789") + end + + it "routes Material presenters to Gdoc::Material and exports the generated doc" do + stub_export + expect(exporter.to_pdf).to eq(fake_pdf) + expect(Exporters::Gdoc::Material).to have_received(:new).with(source, {}) + expect(Exporters::Gdoc::Document).not_to have_received(:new) + expect(raw_service).to have_received(:export_file) + .with("MAT789", "application/pdf", download_dest: instance_of(StringIO)) + end + end + + describe "#to_pdf — export retries" do + let(:source) do + double( + "DocumentPresenter", + content_type: :unit_bundle, + links: { "unit_bundle" => { "gdoc" => { "url" => "https://drive.google.com/open?id=REUSED123" } } } + ) + end + + before { stub_const("#{described_class}::EXPORT_BASE_INTERVAL", 0) } + + it "uses a fresh buffer per attempt so a retried export is not corrupted by partial bytes" do + attempts = 0 + allow(raw_service).to receive(:export_file) do |_id, _mime, download_dest:| + attempts += 1 + download_dest.write("%PDF-clean") + raise Google::Apis::RateLimitError, "rate limited" if attempts == 1 + end + + expect(exporter.to_pdf).to eq("%PDF-clean") + expect(attempts).to eq(2) + end + + it "raises ExportError after exhausting retries on persistent rate limiting" do + stub_const("#{described_class}::EXPORT_TRIES", 2) + allow(raw_service).to receive(:export_file).and_raise(Google::Apis::RateLimitError, "always") + + expect { exporter.to_pdf }.to raise_error(described_class::ExportError, /always/) + expect(raw_service).to have_received(:export_file).twice + end + end + + describe "#to_pdf — unresolvable gdoc link" do + let(:gdoc) { instance_double(Exporters::Gdoc::Document, url: "") } + + before do + allow(Exporters::Gdoc::Document).to receive(:new).and_return(gdoc) + allow(gdoc).to receive(:export).and_return(gdoc) + end + + { + "nil links" => nil, + "missing content_type key" => { "other" => {} }, + "nil gdoc entry" => { "unit_bundle" => { "gdoc" => nil } }, + "blank url in gdoc entry" => { "unit_bundle" => { "gdoc" => { "url" => "" } } } + }.each do |desc, links_value| + context "with #{desc}" do + let(:source) { double("DocumentPresenter", content_type: :unit_bundle, links: links_value) } + + it "resolves the link without crashing and raises ExportError when nothing is available" do + expect { exporter.to_pdf }.to raise_error(described_class::ExportError, /no Google Doc available/) + end + end + end + end + + describe "#to_pdf — Drive failures" do + let(:source) do + double( + "DocumentPresenter", + content_type: :unit_bundle, + links: { "unit_bundle" => { "gdoc" => { "url" => "https://drive.google.com/open?id=REUSED123" } } } + ) + end + + it "wraps Google::Apis::Error as ExportError" do + allow(raw_service).to receive(:export_file).and_raise(Google::Apis::Error, "drive blew up") + expect { exporter.to_pdf }.to raise_error(described_class::ExportError, /drive blew up/) + end + end + + describe "Google Doc id extraction" do + let(:source) { double("DocumentPresenter", content_type: :unit_bundle, links: links) } + + { + "https://drive.google.com/open?id=ABC123" => "ABC123", + "https://docs.google.com/document/d/XYZ789/edit" => "XYZ789", + "https://drive.google.com/file/d/QQQ000/view?usp=sharing" => "QQQ000", + "https://docs.google.com/document/d/QRY111?usp=sharing" => "QRY111" + }.each do |url, expected_id| + context "with URL #{url}" do + let(:links) { { "unit_bundle" => { "gdoc" => { "url" => url } } } } + + it "extracts #{expected_id}" do + stub_export + exporter.to_pdf + expect(raw_service).to have_received(:export_file).with(expected_id, "application/pdf", download_dest: anything) + end + end + end + + context "with a legacy plain-string gdoc link" do + let(:links) { { "unit_bundle" => { "gdoc" => "https://drive.google.com/open?id=LEGACY555" } } } + + it "extracts the id from the bare URL string" do + stub_export + exporter.to_pdf + expect(raw_service).to have_received(:export_file).with("LEGACY555", "application/pdf", download_dest: anything) + end + end + end +end diff --git a/lib/plugins/gdoc_pdf/spec/gdoc_pdf/renderer_spec.rb b/lib/plugins/gdoc_pdf/spec/gdoc_pdf/renderer_spec.rb new file mode 100644 index 0000000..ddcd817 --- /dev/null +++ b/lib/plugins/gdoc_pdf/spec/gdoc_pdf/renderer_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe GdocPdf::Renderer do + it_behaves_like "a PDF renderer" + + subject(:renderer) { described_class.new } + + let(:html) { "<html><body><h1>ignored</h1></body></html>" } + let(:source) { instance_double(DocumentPresenter) } + let(:options) { Exporters::Pdf::RenderOptions.build(source: source) } + let(:fake_pdf) { "%PDF-1.4 fake bytes" } + + describe "protocol" do + it "has identifier :gdoc_pdf" do + expect(described_class.identifier).to eq(:gdoc_pdf) + end + + it "advertises no accessibility capabilities" do + expect(described_class.capabilities).to be_empty + end + + it "delegates available? to Exporter.credentials_present?" do + allow(GdocPdf::Exporter).to receive(:credentials_present?).and_return(true) + expect(described_class.available?).to be true + + allow(GdocPdf::Exporter).to receive(:credentials_present?).and_return(false) + expect(described_class.available?).to be false + end + + it "satisfies RendererRegistry's protocol verifier" do + Exporters::Pdf::RendererRegistry.unregister(:gdoc_pdf) + expect { Exporters::Pdf::RendererRegistry.register(described_class) }.not_to raise_error + end + + it "is refused for accessibility requests (no tagged/pdf_ua capability)" do + # Isolate the capability gate from the availability gate: fetch_for checks + # availability first, so without this stub the host's missing Drive + # credentials would raise Unavailable before the capability check is reached. + allow(GdocPdf::Exporter).to receive(:credentials_present?).and_return(true) + expect { Exporters::Pdf::RendererRegistry.fetch_for(identifier: :gdoc_pdf, accessibility: :pdf_ua) } + .to raise_error(Exporters::Pdf::RendererRegistry::UnsupportedCapability) + end + end + + describe "#call" do + let(:exporter) { instance_double(GdocPdf::Exporter) } + + before do + allow(GdocPdf::Exporter).to receive(:new).with(source, options).and_return(exporter) + allow(exporter).to receive(:to_pdf).and_return(fake_pdf) + end + + it "ignores the HTML and returns the bytes from Exporter#to_pdf" do + expect(renderer.call(html, options: options)).to eq(fake_pdf) + end + + it "passes the source presenter from options to the Exporter" do + renderer.call(html, options: options) + expect(GdocPdf::Exporter).to have_received(:new).with(source, options) + end + + it "raises RenderError when options.source is missing" do + no_source = Exporters::Pdf::RenderOptions.build + expect { renderer.call(html, options: no_source) } + .to raise_error(Exporters::Pdf::RendererRegistry::RenderError, /requires options\.source/) + end + + it "wraps Exporter::ExportError as RendererRegistry::RenderError" do + allow(exporter).to receive(:to_pdf).and_raise(GdocPdf::Exporter::ExportError, "boom") + expect { renderer.call(html, options: options) } + .to raise_error(Exporters::Pdf::RendererRegistry::RenderError, /Gdoc PDF export failed.*boom/) + end + end +end diff --git a/lib/plugins/prince_pdf/spec/prince_pdf/options_translator_spec.rb b/lib/plugins/prince_pdf/spec/prince_pdf/options_translator_spec.rb index 6c58522..8030a89 100644 --- a/lib/plugins/prince_pdf/spec/prince_pdf/options_translator_spec.rb +++ b/lib/plugins/prince_pdf/spec/prince_pdf/options_translator_spec.rb @@ -111,7 +111,8 @@ show_header: true, show_name_date: false, padding: nil, - extra: {} + extra: {}, + source: nil ) expect { described_class.new(poisoned).to_args } diff --git a/package.json b/package.json index 0fd5095..aa89eef 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,12 @@ }, "scripts": { "build": "esbuild app/javascript/*.js --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets --loader:.js=jsx", - "build:css:compile": "sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css ./app/assets/stylesheets/pdf.scss:./app/assets/builds/pdf.css ./app/assets/stylesheets/pdf_plain.scss:./app/assets/builds/pdf_plain.css --no-source-map --load-path=node_modules", + "build:css:compile": "sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css ./app/assets/stylesheets/pdf.scss:./app/assets/builds/pdf.css ./app/assets/stylesheets/pdf_plain.scss:./app/assets/builds/pdf_plain.css ./app/assets/stylesheets/gdoc.scss:./app/assets/builds/gdoc.css --no-source-map --load-path=node_modules", "build:css:prefix": "postcss ./app/assets/builds/application.css --use=autoprefixer --output=./app/assets/builds/application.css", "build:css:prefix:pdf": "postcss ./app/assets/builds/pdf.css --use=autoprefixer --output=./app/assets/builds/pdf.css", "build:css:prefix:pdf_plain": "postcss ./app/assets/builds/pdf_plain.css --use=autoprefixer --output=./app/assets/builds/pdf_plain.css", - "build:css": "yarn build:css:compile && yarn build:css:prefix && yarn build:css:prefix:pdf && yarn build:css:prefix:pdf_plain", + "build:css:prefix:gdoc": "postcss ./app/assets/builds/gdoc.css --use=autoprefixer --output=./app/assets/builds/gdoc.css", + "build:css": "yarn build:css:compile && yarn build:css:prefix && yarn build:css:prefix:pdf && yarn build:css:prefix:pdf_plain && yarn build:css:prefix:gdoc", "watch:css": "nodemon --watch ./app/assets/stylesheets/ --ext scss --exec \"yarn build:css\"" }, "dependencies": { diff --git a/sig/lib/exporters/pdf/render_options.rbs b/sig/lib/exporters/pdf/render_options.rbs index 29964ba..8db6268 100644 --- a/sig/lib/exporters/pdf/render_options.rbs +++ b/sig/lib/exporters/pdf/render_options.rbs @@ -23,6 +23,7 @@ module Exporters attr_reader show_name_date: bool attr_reader padding: Hash[Symbol, untyped] attr_reader extra: Hash[Symbol, untyped] + attr_reader source: untyped def self.build: (**untyped overrides) -> RenderOptions def self.validate!: (Hash[Symbol, untyped] attrs) -> void diff --git a/spec/lib/doc_template/objects/lesson_spec.rb b/spec/lib/doc_template/objects/lesson_spec.rb index a738281..9cb8b01 100644 --- a/spec/lib/doc_template/objects/lesson_spec.rb +++ b/spec/lib/doc_template/objects/lesson_spec.rb @@ -16,8 +16,10 @@ "lesson-title" => "Exploring Ecosystems", "lesson-label" => "required", "lesson-type" => "core", + "estimated-time" => "2 Class Periods", "standards" => "MS-LS2-1, MS-LS2-2", "description" => "<p>In this lesson, we explore ecosystems.</p>", + "vocabulary" => "ecosystem, biome, habitat", "lms-enabled" => "Yes", "lms-summary" => "Explore ecosystems" } @@ -31,8 +33,22 @@ expect(subject.unit_id).to eq "u1" expect(subject.lesson_title).to eq "Exploring Ecosystems" expect(subject.lesson_type).to eq "core" + expect(subject.estimated_time).to eq "2 Class Periods" expect(subject.standards).to eq "MS-LS2-1, MS-LS2-2" expect(subject.description).to eq "<p>In this lesson, we explore ecosystems.</p>" + expect(subject.vocabulary).to eq "ecosystem, biome, habitat" + end + + context "when estimated-time and vocabulary are absent" do + before do + data.delete("estimated-time") + data.delete("vocabulary") + end + + it "defaults to blank strings" do + expect(subject.estimated_time).to eq "" + expect(subject.vocabulary).to eq "" + end end context "when lms-enabled is No" do diff --git a/spec/lib/doc_template/tags/callout_tag_spec.rb b/spec/lib/doc_template/tags/callout_tag_spec.rb new file mode 100644 index 0000000..85c867a --- /dev/null +++ b/spec/lib/doc_template/tags/callout_tag_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DocTemplate::Tags::CalloutTag do + let(:metadata) { instance_double(DocTemplate::Objects::Lesson, subject: "math") } + let(:opts) { { metadata:, context_type: :default } } + let(:tag) { described_class.new } + let(:node) do + html = Nokogiri::HTML(original_content) + html.at_xpath("*//td[contains(., '[#{described_class::TAG_NAME}')]") + end + + subject { tag.parse(node, opts).content } + + describe "legacy 3-row 1-col shape (renders with inline visual + default + icon)" do + let(:original_content) do + <<-HTML + <table> + <tr><td>[#{described_class::TAG_NAME}: math]</td></tr> + <tr><td>Header Text</td></tr> + <tr><td>Body <strong>content</strong></td></tr> + </table> + HTML + end + + it "renders the inline horizontal callout layout" do + expect(subject).to include("o-ld-callout--inline") + expect(subject).to include("o-ld-callout__label") + expect(subject).to include("o-ld-callout__body") + end + + it "renders the default + icon and the type label" do + expect(subject).to include("o-ld-callout__icon") + expect(subject).to include("o-ld-callout__type") + expect(subject).to include("Header Text") + end + + it "preserves the body content HTML" do + expect(subject).to include("Body <strong>content</strong>") + end + end + + describe "3-row 2-col labeled shape (type/text labels in col 1)" do + let(:original_content) do + <<-HTML + <table> + <tr><td colspan="2">[#{described_class::TAG_NAME}]</td></tr> + <tr><td>type</td><td>ATTENDING TO STUDENT IDEAS</td></tr> + <tr><td>text</td><td>If there is time, review and celebrate students' learning.</td></tr> + </table> + HTML + end + + it "renders the value column as the type label, not the label column" do + expect(subject).to include("ATTENDING TO STUDENT IDEAS") + expect(subject).to include("If there is time, review and celebrate students' learning.") + expect(subject).to include("o-ld-callout__type") + end + + it "renders with inline visual + default + icon" do + expect(subject).to include("o-ld-callout--inline") + expect(subject).to include("o-ld-callout__icon") + end + end + + describe "new 1-row 2-col inline shape" do + let(:original_content) do + <<-HTML + <table> + <tr> + <td><p>[#{described_class::TAG_NAME}: math]</p><p>➕</p><p>Callout Type</p></td> + <td><p>Body content goes here</p></td> + </tr> + </table> + HTML + end + + it "renders the inline callout template" do + expect(subject).to include("o-ld-callout--inline") + expect(subject).to include("o-ld-callout__label") + expect(subject).to include("o-ld-callout__body") + end + + it "strips the [callout: ...] marker from the label cell" do + expect(subject).not_to include("[#{described_class::TAG_NAME}: math]") + end + + it "preserves the icon and label HTML in the label cell" do + expect(subject).to include("Callout Type") + expect(subject).to include("➕").or include("➕") + end + + it "preserves the body cell HTML" do + expect(subject).to include("Body content goes here") + end + end +end diff --git a/spec/lib/exporters/gdoc/base_spec.rb b/spec/lib/exporters/gdoc/base_spec.rb new file mode 100644 index 0000000..c1c9898 --- /dev/null +++ b/spec/lib/exporters/gdoc/base_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Exporters::Gdoc::Base do + describe ".file_id_from" do + it "is the inverse of .url_for" do + expect(described_class.file_id_from(described_class.url_for("ABC123"))).to eq("ABC123") + end + + { + "https://drive.google.com/open?id=ABC123" => "ABC123", + "https://drive.google.com/open?id=ABC123&foo=bar" => "ABC123", + "https://drive.google.com/open?id=ABC123#heading=h.x" => "ABC123", + "https://docs.google.com/document/d/XYZ789/edit" => "XYZ789", + "https://drive.google.com/file/d/QQQ000/view?usp=sharing" => "QQQ000", + "https://docs.google.com/document/d/QRY111?usp=sharing" => "QRY111" + }.each do |url, expected_id| + it "extracts #{expected_id} from #{url}" do + expect(described_class.file_id_from(url)).to eq(expected_id) + end + end + + it "returns nil for blank input" do + expect(described_class.file_id_from(nil)).to be_nil + expect(described_class.file_id_from("")).to be_nil + end + + it "returns nil for a URL with no recognizable id" do + expect(described_class.file_id_from("https://example.com/no-id-here")).to be_nil + end + end +end diff --git a/spec/lib/exporters/pdf/render_options_spec.rb b/spec/lib/exporters/pdf/render_options_spec.rb index 18750d5..4f42478 100644 --- a/spec/lib/exporters/pdf/render_options_spec.rb +++ b/spec/lib/exporters/pdf/render_options_spec.rb @@ -15,6 +15,12 @@ expect(opts.show_name_date).to be false expect(opts.metadata).to eq({}) expect(opts.extra).to eq({}) + expect(opts.source).to be_nil + end + + it "carries an opaque source handle when provided" do + record = Object.new + expect(described_class.build(source: record).source).to be(record) end it "accepts overrides" do diff --git a/spec/presenters/document_presenter_spec.rb b/spec/presenters/document_presenter_spec.rb index dcc88b1..4079507 100644 --- a/spec/presenters/document_presenter_spec.rb +++ b/spec/presenters/document_presenter_spec.rb @@ -59,4 +59,115 @@ expect(header.all? { |row| row.is_a?(Array) }).to be true end end + + describe "#gdoc_footer" do + context "with copyright_text Setting" do + before { Settings.set(:documents, "copyright_text" => "© Acme, Spring 2026") } + + it "merges copyright_text into the attribution placeholder" do + result = presenter.gdoc_footer + + expect(result).to eq([ + ["{attribution}"], + ["© Acme, Spring 2026"] + ]) + end + end + + context "without any copyright info" do + it "falls back to the placeholder default" do + result = presenter.gdoc_footer + + expect(result).to eq([ + ["{attribution}"], + ["Copyright attribution here"] + ]) + end + end + end + + describe "#footer_breadcrumb" do + it "joins grade label, unit title, and lesson label with bullets" do + result = presenter.footer_breadcrumb + + expect(result).to eq("Grade 3/Course • Unit 1 • Lesson 5") + end + + context "when all parts are missing" do + let(:document) { Document.new(metadata: { "subject" => "math" }) } + + it "returns nil" do + expect(presenter.footer_breadcrumb).to be_nil + end + end + end + + describe "#materials_summary" do + context "when document has no activity metadata" do + it "returns an empty hash" do + expect(presenter.materials_summary).to eq({}) + end + end + + context "when document has activity metadata" do + let(:document) do + create(:document, + metadata: { + "lesson_title" => "Sample", + "grade" => "6", + "subject" => "science" + }, + activity_metadata: [ + { "activity-materials-student" => "Notebook, [material: worksheet01]", + "activity-materials-class" => "Lesson 7 Slides" }, + { "activity-materials-student" => "[material: worksheet02]", + "activity-materials-pair" => "Calculator" } + ]) + end + + it "aggregates and dedupes materials across activities" do + summary = presenter.materials_summary + + expect(summary["Individual Student Materials"]) + .to eq("Notebook, worksheet01, worksheet02") + expect(summary["Pair Materials"]).to eq("Calculator") + expect(summary["Class Materials"]).to eq("Lesson 7 Slides") + end + + it "resolves [material: id] tokens to italic identifier links when the material exists" do + create(:material, identifier: "worksheet01") + + summary = presenter.materials_summary + + expect(summary["Individual Student Materials"]) + .to include('<a class="o-ld-material">worksheet01</a>') + expect(summary["Individual Student Materials"]) + .not_to include("[material:") + end + + it "strips brackets for unknown material tokens" do + summary = presenter.materials_summary + + expect(summary["Individual Student Materials"]).to include("worksheet01") + expect(summary["Individual Student Materials"]).not_to include("[material:") + end + + it "renders empty rows as 'None'" do + summary = presenter.materials_summary + + expect(summary["Small Group Materials"]).to eq("None") + expect(summary["Teacher Materials"]).to eq("None") + end + + it "always includes all five canonical rows" do + expect(presenter.materials_summary.keys).to eq([ + "Individual Student Materials", + "Pair Materials", + "Small Group Materials", + "Class Materials", + "Teacher Materials" + ]) + end + end + end end