diff --git a/app/renderer/branding.js b/app/renderer/branding.js index a0e7c51..f11d194 100644 --- a/app/renderer/branding.js +++ b/app/renderer/branding.js @@ -16,6 +16,8 @@ const BRAND_FONT_SUGGESTIONS = [ const BRAND_DEFAULT_COLORS = { primary: '#2563EB', accent: '#0EA5E9', text: '#18212B' }; // Mirrors DEFAULT_ANNOTATION_COLORS in core/branding.js: StepForge's own, used until a profile turns its own on. const BRAND_DEFAULT_ANNOTATION_COLORS = { shapes: '#E5484D', numbers: '#1F6FEB', tooltips: '#111827' }; +// Mirrors normalizeProfile's screenshots defaults in core/branding.js. +const BRAND_DEFAULT_SCREENSHOTS = { align: 'left', border: false, borderColor: '#D0D5DD', layout: 'stacked' }; // Mirrors MAX_CSS_CHARS in core/branding.js (a unit test keeps the two in step). const BRAND_CSS_MAX_CHARS = 200000; @@ -41,10 +43,10 @@ const BRAND_AI_REQUEST_MAX_CHARS = 500; // What a profile changes in each export (the issue's support table). const BRAND_SUPPORT = [ - [t('HTML (simple and rich)'), t('Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text and your CSS.')], + [t('HTML (simple and rich)'), t('Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text, screenshot alignment, border and layout, and your CSS.')], ['GitHub Pages', t('Everything the HTML exports get.')], - ['PDF', t('Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers.')], - ['Word', t('Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers.')], + ['PDF', t('Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers, and screenshot alignment, border and layout.')], + ['Word', t('Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers, and screenshot alignment, border and layout.')], ['PowerPoint', t('A theme with your colors and fonts, a title slide in your cover style, logo, footer and slide numbers on every slide.')], ['Wiki.js', t('Logo, banner, header and footer text.')], ['Confluence', t('Logo, banner, header and footer text.')], @@ -60,6 +62,17 @@ const BRAND_COVER_STYLES = [ { value: 'band', label: t('Brand color band') }, ]; +const BRAND_IMAGE_ALIGNMENTS = [ + { value: 'left', label: t('Left') }, + { value: 'center', label: t('Center') }, + { value: 'right', label: t('Right') }, +]; + +const BRAND_STEP_LAYOUTS = [ + { value: 'stacked', label: t('Stacked') }, + { value: 'side-by-side', label: t('Side by side') }, +]; + const BRAND_WATERMARK_POSITIONS = [ { value: 'bottom-right', label: t('Bottom right') }, { value: 'bottom-left', label: t('Bottom left') }, @@ -144,6 +157,13 @@ function brandThumb(asset, label) { } /** The read-only summary of a profile under the Active brand profile list. */ +/** "Centered · Border #D0D5DD · Side by side", or "Left · Stacked" for the defaults. */ +function screenshotSummary(shots = BRAND_DEFAULT_SCREENSHOTS) { + const align = BRAND_IMAGE_ALIGNMENTS.find((a) => a.value === shots.align)?.label || t('Left'); + const layout = BRAND_STEP_LAYOUTS.find((l) => l.value === shots.layout)?.label || t('Stacked'); + return [align, shots.border ? t('Border {color}', { color: shots.borderColor }) : '', layout].filter(Boolean).join(' · '); +} + function brandSummary(data) { if (!data) { return [el('p.setting-desc.brand-none', {}, @@ -177,6 +197,7 @@ function brandSummary(data) { el('dt', {}, t('Cover style')), el('dd', {}, cover), el('dt', {}, t('Header')), el('dd', {}, [profile.header.logo && profile.logo ? t('Logo') : '', profile.header.text].filter(Boolean).join(' · ') || t('None')), el('dt', {}, t('Footer')), el('dd', {}, [profile.footer.text, profile.footer.pageNumbers ? t('Page numbers') : ''].filter(Boolean).join(' · ') || t('None')), + el('dt', {}, t('Screenshots')), el('dd', {}, screenshotSummary(profile.screenshots)), el('dt', {}, t('Watermark')), el('dd', {}, profile.watermark.enabled ? t('Logo, {position}', { position: BRAND_WATERMARK_POSITIONS.find((p) => p.value === profile.watermark.position)?.label.toLowerCase() }) : t('Off')), el('dt', {}, t('Annotations')), el('dd', {}, profile.annotations?.custom ? t('Shapes {shapes} · Number badges {numbers} · Tooltips {tooltips}', profile.annotations) @@ -357,6 +378,7 @@ function showBrandProfileEditor(api, data = null) { html: { css: '', colorScheme: 'light', ...(base?.html || {}) }, watermark: { enabled: false, position: 'bottom-right', opacity: 0.6, ...(base?.watermark || {}) }, annotations: { custom: false, ...BRAND_DEFAULT_ANNOTATION_COLORS, ...(base?.annotations || {}) }, + screenshots: { ...BRAND_DEFAULT_SCREENSHOTS, ...(base?.screenshots || {}) }, }; // Errors from Preview and Save show in the footer beside those buttons. const footerStatus = el('span.brand-footer-status.error', { role: 'alert' }); @@ -535,6 +557,16 @@ function showBrandProfileEditor(api, data = null) { const opacityLabel = el('output', {}, `${Math.round(draft.watermark.opacity * 100)}%`); opacity.addEventListener('input', () => { draft.watermark.opacity = Number(opacity.value); opacityLabel.textContent = `${Math.round(draft.watermark.opacity * 100)}%`; }); + // Screenshots: alignment, border and step layout in PDF, Word and HTML. + const imageAlign = brandSelect(draft.screenshots.align, BRAND_IMAGE_ALIGNMENTS, { 'aria-label': t('Screenshot alignment') }); + imageAlign.addEventListener('change', () => { draft.screenshots.align = imageAlign.value; }); + const stepLayout = brandSelect(draft.screenshots.layout, BRAND_STEP_LAYOUTS, { 'aria-label': t('Step layout') }); + stepLayout.addEventListener('change', () => { draft.screenshots.layout = stepLayout.value; }); + const borderOn = el('input', { type: 'checkbox', checked: draft.screenshots.border }); + const borderColorRow = colorRow(draft.screenshots, 'borderColor', t('Border color'), t('A thin line around each screenshot.')); + borderColorRow.hidden = !draft.screenshots.border; + borderOn.addEventListener('change', () => { draft.screenshots.border = borderOn.checked; borderColorRow.hidden = !borderOn.checked; }); + // Advanced CSS const css = el('textarea.brand-css-input', { rows: 7, spellcheck: false, placeholder: '.doc-header { padding: 2rem; }\n.step-head h2 { color: var(--brand-primary); }', 'aria-label': t('Advanced CSS') }); css.value = draft.html.css; @@ -690,6 +722,11 @@ function showBrandProfileEditor(api, data = null) { settingsCard(t('Layout'), settingRow(t('Cover style'), t('The title page of PDF, Word and PowerPoint, the top of HTML pages and the GIF title card.'), cover), settingRow(t('HTML color scheme'), t('Brand colors are usually picked for light pages.'), scheme)), + settingsCard(t('Screenshots'), + settingRow(t('Alignment'), t('Where screenshots and flowcharts narrower than the page sit in PDF, Word and HTML.'), imageAlign), + settingRow(t('Border around screenshots'), t('Sets screenshots apart from the page in PDF, Word and HTML.'), makeSwitch(borderOn, t('Border around screenshots'))), + borderColorRow, + settingRow(t('Step layout'), t('Side by side puts each screenshot on the left and its description on the right in PDF, Word and HTML. Steps without a description stay stacked.'), stepLayout)), settingsCard(t('Header and footer'), settingRow(t('Header text'), t('Top of every PDF and Word page; under the logo in HTML and wiki pages.'), textInput(draft.header, 'text', t('Acme Corp · IT Service Desk'), 160)), settingRow(t('Logo in page header'), t('PDF and Word pages after the cover.'), toggle(draft.header, 'logo', t('Logo in page header'))), diff --git a/core/agent-brand-tools.js b/core/agent-brand-tools.js index 0e411f5..47c6c17 100644 --- a/core/agent-brand-tools.js +++ b/core/agent-brand-tools.js @@ -3,7 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { - BrandingStore, normalizeProfile, checkFileSize, COVER_STYLES, WATERMARK_POSITIONS, COLOR_SCHEMES, MAX_CSS_CHARS, + BrandingStore, normalizeProfile, checkFileSize, COVER_STYLES, WATERMARK_POSITIONS, COLOR_SCHEMES, IMAGE_ALIGNMENTS, STEP_LAYOUTS, MAX_CSS_CHARS, } = require('./branding'); const { STYLE_REFERENCE } = require('./ai-brand-css'); const { Settings } = require('./settings'); @@ -71,6 +71,19 @@ const PROFILE_FIELDS = { }, additionalProperties: false, }, + screenshots: { + type: 'object', + description: 'How PDF, Word and HTML exports place step screenshots. align: left, center or right (also flowcharts). ' + + 'border: a thin line around each screenshot, in border_color. layout: stacked (screenshot under the text) or ' + + 'side-by-side (screenshot left, description right; steps without a description stay stacked).', + properties: { + align: { type: 'string', enum: IMAGE_ALIGNMENTS }, + border: { type: 'boolean' }, + border_color: colorSchema, + layout: { type: 'string', enum: STEP_LAYOUTS }, + }, + additionalProperties: false, + }, annotation_colors: { type: 'object', description: 'Colors new annotations start in, used only when custom is true; otherwise StepForge\'s (red shapes, blue number badges, dark tooltips).', @@ -121,6 +134,12 @@ function createBrandTools({ settingsDir, requireEnabled, agentSettings, onChange header: profile.header, footer: { text: profile.footer.text, page_numbers: profile.footer.pageNumbers }, watermark: profile.watermark, + screenshots: { + align: profile.screenshots.align, + border: profile.screenshots.border, + border_color: profile.screenshots.borderColor, + layout: profile.screenshots.layout, + }, annotation_colors: profile.annotations, css: profile.html.css, updated_at: profile.updatedAt, @@ -179,6 +198,13 @@ function createBrandTools({ settingsDir, requireEnabled, agentSettings, onChange next.watermark.opacity = opacity; } } + if (has(fields, 'screenshots')) { + const shots = object(fields.screenshots, 'screenshots', ['align', 'border', 'border_color', 'layout']); + if (has(shots, 'align')) next.screenshots.align = choice(shots.align, IMAGE_ALIGNMENTS, 'screenshots.align'); + if (has(shots, 'border')) next.screenshots.border = bool(shots.border, 'screenshots.border'); + if (has(shots, 'border_color')) next.screenshots.borderColor = color(shots.border_color, 'screenshots.border_color'); + if (has(shots, 'layout')) next.screenshots.layout = choice(shots.layout, STEP_LAYOUTS, 'screenshots.layout'); + } if (has(fields, 'annotation_colors')) { const annotations = object(fields.annotation_colors, 'annotation_colors', ['custom', 'shapes', 'numbers', 'tooltips']); if (has(annotations, 'custom')) next.annotations.custom = bool(annotations.custom, 'annotation_colors.custom'); @@ -270,7 +296,7 @@ function createBrandTools({ settingsDir, requireEnabled, agentSettings, onChange }, { name: 'update_brand_profile', - description: 'Change a brand profile. Leave a field out to keep it; in colors, fonts, header, footer, watermark and annotation_colors, ' + description: 'Change a brand profile. Leave a field out to keep it; in colors, fonts, header, footer, watermark, screenshots and annotation_colors, ' + 'only the keys you send change.', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, inputSchema: { diff --git a/core/branding.js b/core/branding.js index d4fca48..9fb757d 100644 --- a/core/branding.js +++ b/core/branding.js @@ -27,6 +27,9 @@ const { sanitizeSvg, svgSize } = require('./svg-sanitize'); const COVER_STYLES = ['standard', 'band', 'centered']; const WATERMARK_POSITIONS = ['bottom-right', 'bottom-left', 'top-right', 'top-left']; const COLOR_SCHEMES = ['light', 'auto']; +const IMAGE_ALIGNMENTS = ['left', 'center', 'right']; +const STEP_LAYOUTS = ['stacked', 'side-by-side']; +const DEFAULT_BORDER_COLOR = '#D0D5DD'; const DEFAULT_COLORS = { primary: '#2563EB', accent: '#0EA5E9', text: '#18212B' }; // StepForge's own annotation colors: red shapes and click markers, blue // number badges, near-black tooltips. A profile only changes them when its @@ -149,6 +152,14 @@ function normalizeProfile(raw = {}, { id = raw.id, createdAt = raw.createdAt, up position: oneOf(raw.watermark?.position, WATERMARK_POSITIONS, 'bottom-right'), opacity: Number.isFinite(opacity) ? Math.round(Math.max(0.05, Math.min(1, opacity)) * 100) / 100 : 0.6, }, + // How PDF, Word and HTML place step screenshots. The defaults are what + // those exports did before these options existed. + screenshots: { + align: oneOf(raw.screenshots?.align, IMAGE_ALIGNMENTS, 'left'), + border: raw.screenshots?.border === true, + borderColor: normalizeColor(raw.screenshots?.borderColor, DEFAULT_BORDER_COLOR), + layout: oneOf(raw.screenshots?.layout, STEP_LAYOUTS, 'stacked'), + }, }; } @@ -625,6 +636,7 @@ function loadBranding(resolved) { cover: p.cover, html: p.html, watermark: p.watermark, + screenshots: p.screenshots, }; } @@ -670,6 +682,9 @@ module.exports = { COVER_STYLES, WATERMARK_POSITIONS, COLOR_SCHEMES, + IMAGE_ALIGNMENTS, + STEP_LAYOUTS, + DEFAULT_BORDER_COLOR, DEFAULT_COLORS, DEFAULT_ANNOTATION_COLORS, FONT_SUGGESTIONS, diff --git a/core/locales/es.js b/core/locales/es.js index 4396c22..d4b5828 100644 --- a/core/locales/es.js +++ b/core/locales/es.js @@ -853,10 +853,10 @@ 'HTML (simple)': 'HTML (simple)', 'Image bundle': 'Paquete de imágenes', 'HTML (simple and rich)': 'HTML (simple y enriquecido)', - 'Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text and your CSS.': 'Logotipo, banner, colores, fuentes (las fuentes subidas se incrustan), estilo de portada, texto del pie de página y tu CSS.', + "Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text, screenshot alignment, border and layout, and your CSS.": "Logotipo, banner, colores, fuentes (las fuentes subidas se incrustan), estilo de portada, texto del pie de página, alineación, borde y diseño de las capturas de pantalla, y tu CSS.", 'Everything the HTML exports get.': 'Todo lo que reciben las exportaciones HTML.', - 'Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers.': 'Logotipo, banner, colores, las fuentes integradas más parecidas, estilo de portada, encabezado y pie de página con números de página.', - 'Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers.': 'Estilos con tus fuentes y colores, logotipo y banner en la portada, encabezado y pie de página con números de página.', + "Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers, and screenshot alignment, border and layout.": "Logotipo, banner, colores, las fuentes integradas más parecidas, estilo de portada, encabezado y pie de página con números de página, y alineación, borde y diseño de las capturas de pantalla.", + "Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers, and screenshot alignment, border and layout.": "Estilos con tus fuentes y colores, logotipo y banner en la portada, encabezado y pie de página con números de página, y alineación, borde y diseño de las capturas de pantalla.", 'A theme with your colors and fonts, a title slide in your cover style, logo, footer and slide numbers on every slide.': 'Un tema con tus colores y fuentes, una diapositiva de título con tu estilo de portada, y logotipo, pie de página y números en cada diapositiva.', 'Logo, banner, header and footer text.': 'Logotipo, banner y textos de encabezado y pie de página.', 'Logo, banner, header and footer text, and the accent bar color.': 'Logotipo, banner, textos de encabezado y pie de página, y el color de la barra de acento.', @@ -1703,6 +1703,19 @@ "Page layout": "Diseño de página", "Contents": "Contenido", "Screenshots": "Capturas de pantalla", + "Center": "Centro", + "Stacked": "Apilado", + "Side by side": "Lado a lado", + "Border {color}": "Borde {color}", + "Screenshot alignment": "Alineación de las capturas de pantalla", + "Step layout": "Diseño de los pasos", + "Border color": "Color del borde", + "A thin line around each screenshot.": "Una línea fina alrededor de cada captura de pantalla.", + "Alignment": "Alineación", + "Where screenshots and flowcharts narrower than the page sit in PDF, Word and HTML.": "Dónde se colocan las capturas de pantalla y los diagramas de flujo más estrechos que la página en PDF, Word y HTML.", + "Border around screenshots": "Borde alrededor de las capturas de pantalla", + "Sets screenshots apart from the page in PDF, Word and HTML.": "Separa visualmente las capturas de pantalla de la página en PDF, Word y HTML.", + "Side by side puts each screenshot on the left and its description on the right in PDF, Word and HTML. Steps without a description stay stacked.": "Lado a lado coloca cada captura de pantalla a la izquierda y su descripción a la derecha en PDF, Word y HTML. Los pasos sin descripción siguen apilados.", "A4 (210 × 297 mm)": "A4 (210 × 297 mm)", "US Letter (8.5 × 11 in)": "Carta de EE. UU. (8,5 × 11 pulgadas)", "A3 (297 × 420 mm)": "A3 (297 × 420 mm)", diff --git a/core/locales/fr.js b/core/locales/fr.js index fbf242b..d0b5439 100644 --- a/core/locales/fr.js +++ b/core/locales/fr.js @@ -853,10 +853,10 @@ 'HTML (simple)': 'HTML (simple)', 'Image bundle': 'Lot d’images', 'HTML (simple and rich)': 'HTML (simple et enrichi)', - 'Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text and your CSS.': 'Logo, bannière, couleurs, polices (les polices envoyées sont intégrées), style de couverture, texte de pied de page et votre CSS.', + "Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text, screenshot alignment, border and layout, and your CSS.": "Logo, bannière, couleurs, polices (les polices envoyées sont intégrées), style de couverture, texte de pied de page, alignement, bordure et disposition des captures d’écran, et votre CSS.", 'Everything the HTML exports get.': 'Tout ce que reçoivent les exports HTML.', - 'Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers.': 'Logo, bannière, couleurs, polices intégrées les plus proches, style de couverture, en-tête et pied de page avec numéros de page.', - 'Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers.': 'Styles avec vos polices et couleurs, logo et bannière sur la couverture, en-tête et pied de page avec numéros de page.', + "Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers, and screenshot alignment, border and layout.": "Logo, bannière, couleurs, polices intégrées les plus proches, style de couverture, en-tête et pied de page avec numéros de page, et alignement, bordure et disposition des captures d’écran.", + "Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers, and screenshot alignment, border and layout.": "Styles avec vos polices et couleurs, logo et bannière sur la couverture, en-tête et pied de page avec numéros de page, et alignement, bordure et disposition des captures d’écran.", 'A theme with your colors and fonts, a title slide in your cover style, logo, footer and slide numbers on every slide.': 'Un thème avec vos couleurs et polices, une diapositive de titre dans votre style de couverture, et logo, pied de page et numéros sur chaque diapositive.', 'Logo, banner, header and footer text.': 'Logo, bannière, textes d’en-tête et de pied de page.', 'Logo, banner, header and footer text, and the accent bar color.': 'Logo, bannière, textes d’en-tête et de pied de page, et couleur de la barre d’accent.', @@ -1703,6 +1703,19 @@ "Page layout": "Mise en page", "Contents": "Contenu", "Screenshots": "Captures d’écran", + "Center": "Centre", + "Stacked": "Empilée", + "Side by side": "Côte à côte", + "Border {color}": "Bordure {color}", + "Screenshot alignment": "Alignement des captures d’écran", + "Step layout": "Disposition des étapes", + "Border color": "Couleur de bordure", + "A thin line around each screenshot.": "Un trait fin autour de chaque capture d’écran.", + "Alignment": "Alignement", + "Where screenshots and flowcharts narrower than the page sit in PDF, Word and HTML.": "Position des captures d’écran et des organigrammes moins larges que la page en PDF, Word et HTML.", + "Border around screenshots": "Bordure autour des captures d’écran", + "Sets screenshots apart from the page in PDF, Word and HTML.": "Détache les captures d’écran de la page en PDF, Word et HTML.", + "Side by side puts each screenshot on the left and its description on the right in PDF, Word and HTML. Steps without a description stay stacked.": "Côte à côte place chaque capture d’écran à gauche et sa description à droite en PDF, Word et HTML. Les étapes sans description restent empilées.", "A4 (210 × 297 mm)": "A4 (210 × 297 mm)", "US Letter (8.5 × 11 in)": "Lettre US (8,5 × 11 pouces)", "A3 (297 × 420 mm)": "A3 (297 × 420 mm)", diff --git a/core/locales/ja.js b/core/locales/ja.js index f00ee05..378e165 100644 --- a/core/locales/ja.js +++ b/core/locales/ja.js @@ -853,10 +853,10 @@ 'HTML (simple)': 'HTML(シンプル)', 'Image bundle': '画像セット', 'HTML (simple and rich)': 'HTML(シンプルとリッチ)', - 'Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text and your CSS.': 'ロゴ、バナー、色、フォント(アップロードしたフォントは埋め込まれます)、表紙のスタイル、フッターのテキスト、独自の CSS。', + "Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text, screenshot alignment, border and layout, and your CSS.": "ロゴ、バナー、色、フォント(アップロードしたフォントは埋め込まれます)、表紙のスタイル、フッターのテキスト、スクリーンショットの配置・枠線・レイアウト、独自の CSS。", 'Everything the HTML exports get.': 'HTML エクスポートと同じすべての項目。', - 'Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers.': 'ロゴ、バナー、色、最も近い組み込みフォント、表紙のスタイル、ページ番号付きのヘッダーとフッター。', - 'Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers.': 'フォントと色を使ったスタイル、表紙のロゴとバナー、ページ番号付きのヘッダーとフッター。', + "Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers, and screenshot alignment, border and layout.": "ロゴ、バナー、色、最も近い組み込みフォント、表紙のスタイル、ページ番号付きのヘッダーとフッター、スクリーンショットの配置・枠線・レイアウト。", + "Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers, and screenshot alignment, border and layout.": "フォントと色を使ったスタイル、表紙のロゴとバナー、ページ番号付きのヘッダーとフッター、スクリーンショットの配置・枠線・レイアウト。", 'A theme with your colors and fonts, a title slide in your cover style, logo, footer and slide numbers on every slide.': '色とフォントを使ったテーマ、表紙スタイルのタイトルスライド、すべてのスライドのロゴ、フッター、スライド番号。', 'Logo, banner, header and footer text.': 'ロゴ、バナー、ヘッダーとフッターのテキスト。', 'Logo, banner, header and footer text, and the accent bar color.': 'ロゴ、バナー、ヘッダーとフッターのテキスト、アクセントバーの色。', @@ -1703,6 +1703,19 @@ "Page layout": "ページレイアウト", "Contents": "内容", "Screenshots": "スクリーンショット", + "Center": "中央", + "Stacked": "縦に並べる", + "Side by side": "横に並べる", + "Border {color}": "枠線 {color}", + "Screenshot alignment": "スクリーンショットの配置", + "Step layout": "ステップのレイアウト", + "Border color": "枠線の色", + "A thin line around each screenshot.": "各スクリーンショットを囲む細い線。", + "Alignment": "配置", + "Where screenshots and flowcharts narrower than the page sit in PDF, Word and HTML.": "PDF、Word、HTML で、ページより幅の狭いスクリーンショットとフローチャートを置く位置。", + "Border around screenshots": "スクリーンショットの枠線", + "Sets screenshots apart from the page in PDF, Word and HTML.": "PDF、Word、HTML でスクリーンショットをページから見分けやすくします。", + "Side by side puts each screenshot on the left and its description on the right in PDF, Word and HTML. Steps without a description stay stacked.": "横に並べると、PDF、Word、HTML で各スクリーンショットが左、説明が右になります。説明のないステップは縦に並んだままです。", "A4 (210 × 297 mm)": "A4(210 × 297 mm)", "US Letter (8.5 × 11 in)": "米国レター(8.5 × 11インチ)", "A3 (297 × 420 mm)": "A3(297 × 420 mm)", diff --git a/core/locales/ru.js b/core/locales/ru.js index f1deb7c..0166e49 100644 --- a/core/locales/ru.js +++ b/core/locales/ru.js @@ -853,10 +853,10 @@ 'HTML (simple)': 'HTML (простой)', 'Image bundle': 'Набор изображений', 'HTML (simple and rich)': 'HTML (простой и расширенный)', - 'Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text and your CSS.': 'Логотип, баннер, цвета, шрифты (загруженные шрифты встраиваются), стиль обложки, текст нижнего колонтитула и ваш CSS.', + "Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text, screenshot alignment, border and layout, and your CSS.": "Логотип, баннер, цвета, шрифты (загруженные шрифты встраиваются), стиль обложки, текст нижнего колонтитула, выравнивание, рамка и расположение снимков экрана, а также ваш CSS.", 'Everything the HTML exports get.': 'Всё то же, что и в экспорте HTML.', - 'Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers.': 'Логотип, баннер, цвета, ближайшие встроенные шрифты, стиль обложки, колонтитулы с номерами страниц.', - 'Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers.': 'Стили с вашими шрифтами и цветами, логотип и баннер на обложке, колонтитулы с номерами страниц.', + "Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers, and screenshot alignment, border and layout.": "Логотип, баннер, цвета, ближайшие встроенные шрифты, стиль обложки, колонтитулы с номерами страниц, а также выравнивание, рамка и расположение снимков экрана.", + "Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers, and screenshot alignment, border and layout.": "Стили с вашими шрифтами и цветами, логотип и баннер на обложке, колонтитулы с номерами страниц, а также выравнивание, рамка и расположение снимков экрана.", 'A theme with your colors and fonts, a title slide in your cover style, logo, footer and slide numbers on every slide.': 'Тема с вашими цветами и шрифтами, титульный слайд в стиле обложки, логотип, нижний колонтитул и номера на каждом слайде.', 'Logo, banner, header and footer text.': 'Логотип, баннер, текст верхнего и нижнего колонтитулов.', 'Logo, banner, header and footer text, and the accent bar color.': 'Логотип, баннер, текст колонтитулов и цвет акцентной полосы.', @@ -1703,6 +1703,19 @@ "Page layout": "Макет страницы", "Contents": "Содержимое", "Screenshots": "Снимки экрана", + "Center": "По центру", + "Stacked": "Друг под другом", + "Side by side": "Рядом", + "Border {color}": "Рамка {color}", + "Screenshot alignment": "Выравнивание снимков экрана", + "Step layout": "Расположение шагов", + "Border color": "Цвет рамки", + "A thin line around each screenshot.": "Тонкая линия вокруг каждого снимка экрана.", + "Alignment": "Выравнивание", + "Where screenshots and flowcharts narrower than the page sit in PDF, Word and HTML.": "Где располагаются снимки экрана и блок-схемы, которые уже страницы, в PDF, Word и HTML.", + "Border around screenshots": "Рамка вокруг снимков экрана", + "Sets screenshots apart from the page in PDF, Word and HTML.": "Отделяет снимки экрана от страницы в PDF, Word и HTML.", + "Side by side puts each screenshot on the left and its description on the right in PDF, Word and HTML. Steps without a description stay stacked.": "В режиме «Рядом» снимок экрана находится слева, а его описание справа в PDF, Word и HTML. Шаги без описания остаются друг под другом.", "A4 (210 × 297 mm)": "A4 (210 × 297 мм)", "US Letter (8.5 × 11 in)": "US Letter (8,5 × 11 дюймов)", "A3 (297 × 420 mm)": "A3 (297 × 420 мм)", diff --git a/core/locales/zh-CN.js b/core/locales/zh-CN.js index b3d5e3a..25063b1 100644 --- a/core/locales/zh-CN.js +++ b/core/locales/zh-CN.js @@ -853,10 +853,10 @@ 'HTML (simple)': 'HTML(简单)', 'Image bundle': '图片包', 'HTML (simple and rich)': 'HTML(简单和丰富)', - 'Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text and your CSS.': '徽标、横幅、颜色、字体(上传的字体会被嵌入)、封面样式、页脚文本和你的 CSS。', + "Logo, banner, colors, fonts (uploaded fonts are embedded), cover style, footer text, screenshot alignment, border and layout, and your CSS.": "徽标、横幅、颜色、字体(上传的字体会被嵌入)、封面样式、页脚文本、截图的对齐方式、边框和布局,以及你的 CSS。", 'Everything the HTML exports get.': '与 HTML 导出相同的全部内容。', - 'Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers.': '徽标、横幅、颜色、最接近的内置字体、封面样式、带页码的页眉和页脚。', - 'Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers.': '使用你的字体和颜色的样式、封面上的徽标和横幅、带页码的页眉和页脚。', + "Logo, banner, colors, the closest built-in fonts, cover style, page header and footer with page numbers, and screenshot alignment, border and layout.": "徽标、横幅、颜色、最接近的内置字体、封面样式、带页码的页眉和页脚,以及截图的对齐方式、边框和布局。", + "Styles with your fonts and colors, logo and banner on the cover, page header and footer with page numbers, and screenshot alignment, border and layout.": "使用你的字体和颜色的样式、封面上的徽标和横幅、带页码的页眉和页脚,以及截图的对齐方式、边框和布局。", 'A theme with your colors and fonts, a title slide in your cover style, logo, footer and slide numbers on every slide.': '使用你的颜色和字体的主题、采用你封面样式的标题幻灯片,以及每张幻灯片上的徽标、页脚和编号。', 'Logo, banner, header and footer text.': '徽标、横幅、页眉和页脚文本。', 'Logo, banner, header and footer text, and the accent bar color.': '徽标、横幅、页眉和页脚文本,以及强调条颜色。', @@ -1703,6 +1703,19 @@ "Page layout": "页面布局", "Contents": "内容", "Screenshots": "截图", + "Center": "居中", + "Stacked": "上下排列", + "Side by side": "并排", + "Border {color}": "边框 {color}", + "Screenshot alignment": "截图对齐方式", + "Step layout": "步骤布局", + "Border color": "边框颜色", + "A thin line around each screenshot.": "每张截图周围的一条细线。", + "Alignment": "对齐方式", + "Where screenshots and flowcharts narrower than the page sit in PDF, Word and HTML.": "在 PDF、Word 和 HTML 中,比页面窄的截图和流程图的位置。", + "Border around screenshots": "截图边框", + "Sets screenshots apart from the page in PDF, Word and HTML.": "在 PDF、Word 和 HTML 中让截图与页面区分开。", + "Side by side puts each screenshot on the left and its description on the right in PDF, Word and HTML. Steps without a description stay stacked.": "并排时,在 PDF、Word 和 HTML 中每张截图在左、说明在右。没有说明的步骤仍上下排列。", "A4 (210 × 297 mm)": "A4(210 × 297 毫米)", "US Letter (8.5 × 11 in)": "美国信纸(8.5 × 11 英寸)", "A3 (297 × 420 mm)": "A3(297 × 420 毫米)", diff --git a/docs/BRANDING.md b/docs/BRANDING.md index 82d3eff..b426c7e 100644 --- a/docs/BRANDING.md +++ b/docs/BRANDING.md @@ -53,10 +53,10 @@ there, or one saved in an export template, takes their place. | Export | What it gets | | --- | --- | -| HTML (simple and rich) | Logo, banner, header text, colors, fonts (uploaded font files are built in), cover style, footer text and your [Advanced CSS](#advanced-css) | +| HTML (simple and rich) | Logo, banner, header text, colors, fonts (uploaded font files are built in), cover style, footer text, [screenshot options](#screenshots) and your [Advanced CSS](#advanced-css) | | GitHub Pages | Everything the HTML exports get | -| PDF | Logo and banner on the cover in its style, colors, the closest built-in fonts, a header (logo and text) and footer (text and "Page 2 of 9") on every page after the cover | -| Word | Normal and Heading styles in your fonts and colors, logo and banner on the cover, a header and footer with page numbers on every page after the cover | +| PDF | Logo and banner on the cover in its style, colors, the closest built-in fonts, a header (logo and text) and footer (text and "Page 2 of 9") on every page after the cover, and [screenshot options](#screenshots) | +| Word | Normal and Heading styles in your fonts and colors, logo and banner on the cover, a header and footer with page numbers on every page after the cover, and [screenshot options](#screenshots) | | PowerPoint | A theme with your colors and fonts, a title slide in your cover style, and the logo, footer text and slide numbers on every slide after it | | Wiki.js, Confluence | Logo, banner, header and footer text. Publishing uploads the images too | | Markdown | Logo, banner, header and footer text, and the color of the accent line | @@ -76,6 +76,24 @@ A few things to know: - A watermark picked in the Export dialog (image bundle) replaces the profile's. +## Screenshots + +The **Screenshots** card sets how PDF, Word and HTML exports (and GitHub +Pages) place each step's screenshot. Every option starts at what exports +always did. + +- **Alignment**: **Left**, **Center** or **Right**. It moves screenshots and + flowcharts that are narrower than the page, such as a tall screenshot in a + PDF or one smaller than the **Screenshot width** in Word. +- **Border around screenshots**: a thin line in the **Border color** around + each screenshot. HTML pages always draw a light border; this sets its color. +- **Step layout**: **Stacked** puts the screenshot under the step's text. + **Side by side** puts the screenshot on the left and the description on the + right (55% and 45% of the width). Callouts keep their place: one placed + after the description sits under it, one placed before the image sits above + the screenshot. Steps without a description or a screenshot stay stacked. + HTML pages go back to one column on narrow screens. + ## Advanced CSS Advanced CSS styles the **HTML exports and GitHub Pages** only. Everything else diff --git a/exporters/branding.js b/exporters/branding.js index c80f098..2bb9e3e 100644 --- a/exporters/branding.js +++ b/exporters/branding.js @@ -105,8 +105,23 @@ function overlayWatermark(img, mark, { position = 'bottom-right', opacity = 0.6 raster.drawImage(img, scaled, Math.max(0, x), Math.max(0, y)); } +/** + * How the brand places step screenshots (core/branding.js `screenshots`); + * without a profile, the way exports always placed them. + */ +function screenshotOptions(ast) { + const shots = ast?.branding?.screenshots; + return { + align: shots?.align || 'left', + border: shots?.border === true, + borderColor: shots?.borderColor || '#D0D5DD', + layout: shots?.layout || 'stacked', + }; +} + module.exports = { withBrand, + screenshotOptions, brandRaster, fitSize, fitRaster, diff --git a/exporters/docx.js b/exporters/docx.js index cedf253..183c32a 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -8,7 +8,7 @@ const { encodePng } = require('../core/png'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText, wordCodeRuns, wordCodeShading, flowchartImageKey, flowchartAlt } = require('./common'); const { highlightCode } = require('../core/code-highlight'); const { guideMetaLines, guideSummary, tocEntries } = require('./document-layout'); -const { fitSize, textOn } = require('./branding'); +const { fitSize, textOn, screenshotOptions } = require('./branding'); /** * DOCX exporter: WordprocessingML built directly (no dependency), one @@ -60,7 +60,8 @@ function run(text, { bold = false, size = 22, font = '', color = '' } = {}) { ).join(''); } -function drawing(relId, widthPx, heightPx, maxWidthTwips, alt, name = `Screenshot ${relId}`) { +/** An inline picture; `border` is a hex color for a thin line around it. */ +function drawing(relId, widthPx, heightPx, maxWidthTwips, alt, name = `Screenshot ${relId}`, { border = '' } = {}) { // scale to maxWidth (twips -> px at 96dpi: twips/15) const maxWpx = maxWidthTwips / 15; let w = widthPx, h = heightPx; @@ -74,7 +75,9 @@ function drawing(relId, widthPx, heightPx, maxWidthTwips, alt, name = `Screensho `` + `` + `` + - `` + + `` + + (border ? `` : '') + + '' + ``; } @@ -163,6 +166,21 @@ function table(rows) { return `${borders}${grid}${body}`; } +// Side by side: the screenshot's column and the description's, inside A4 margins. +const SPLIT_IMAGE_TWIPS = 5300; +const SPLIT_TEXT_TWIPS = 4338; + +/** A borderless two-column table: screenshot on the left, description on the right. */ +function splitTable(left, right) { + const none = ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'].map((s) => ``).join(''); + const cell = (width, content, margin) => `` + + `${content}`; + return `` + + `${none}` + + `` + + `${cell(SPLIT_IMAGE_TWIPS, left, 'right')}${cell(SPLIT_TEXT_TWIPS, right, 'left')}`; +} + function fontsXml(name) { const font = escapeXml(name); return ``; @@ -340,6 +358,10 @@ function exportDocx(ast, outDir, template = {}) { body.push(pageBreak()); } + // The brand's screenshot alignment, border and step layout. + const shots = screenshotOptions(ast); + const alignProps = shots.align === 'left' ? '' : ``; + const emitTextBlock = (tb) => { const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; @@ -364,14 +386,14 @@ function exportDocx(ast, outDir, template = {}) { rest, } = stepContentGroups(step); // Text blocks and the flowchart block, at their positions. - const emit = (tb) => { + const emit = (tb, maxTwips = tpl.imageWidthTwips) => { if (tb.kind !== 'flowchart') { emitTextBlock(tb); return; } const chart = images.get(flowchartImageKey(step.stepId)); if (!chart) return; const relId = imageRel(`flowchart${relCounter + 1}.png`, encodePng(chart)); // Drawn at 2x: show it at its natural size, never wider than a screenshot. - const widthTwips = Math.min(tpl.imageWidthTwips, Math.round((chart.width / 2) * 15)); - body.push(p(drawing(relId, chart.width, chart.height, widthTwips, flowchartAlt(step)))); + const widthTwips = Math.min(maxTwips, Math.round((chart.width / 2) * 15)); + body.push(p(drawing(relId, chart.width, chart.height, widthTwips, flowchartAlt(step)), alignProps)); chartLinkParagraph(step.flowchartImage); stepImageCount += 1; }; @@ -383,20 +405,34 @@ function exportDocx(ast, outDir, template = {}) { headingParagraphProps(step.depth, step.forceNewPage) )); for (const tb of afterTitle) emit(tb); - for (const tb of beforeDescription) emit(tb); - if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: ink }))); - for (const tb of afterDescription) emit(tb); - for (const tb of beforeImage) emit(tb); - const img = images.get(step.stepId); - if (img) { + const description = step.descriptionText ? p(run(step.descriptionText, { size: 20, color: ink })) : ''; + const screenshot = (maxTwips) => { const relId = ++relCounter; const name = `image${relCounter}.png`; media.push({ name, data: encodePng(img) }); rels.push(``); - body.push(p(drawing(relId, img.width, img.height, tpl.imageWidthTwips, step.altText))); - if (step.image?.flowchart) chartLinkParagraph(step.image); stepImageCount += 1; + return p(drawing(relId, img.width, img.height, maxTwips, step.altText, undefined, { border: shots.border ? shots.borderColor : '' }), alignProps); + }; + if (img && description && shots.layout === 'side-by-side') { + // Screenshot on the left, description on the right. Callouts keep their + // place: before the image above it, after the description under it. + for (const tb of beforeDescription) emit(tb); + const cell = (fill) => { const start = body.length; fill(); return body.splice(start).join(''); }; + const left = cell(() => { for (const tb of beforeImage) emit(tb, SPLIT_IMAGE_TWIPS); body.push(screenshot(SPLIT_IMAGE_TWIPS)); }); + const right = cell(() => { body.push(description); for (const tb of afterDescription) emit(tb, SPLIT_TEXT_TWIPS); }); + body.push(splitTable(left, right)); + if (step.image?.flowchart) chartLinkParagraph(step.image); + } else { + for (const tb of beforeDescription) emit(tb); + if (description) body.push(description); + for (const tb of afterDescription) emit(tb); + for (const tb of beforeImage) emit(tb); + if (img) { + body.push(screenshot(tpl.imageWidthTwips)); + if (step.image?.flowchart) chartLinkParagraph(step.image); + } } for (const tb of afterImage) emit(tb); diff --git a/exporters/html.js b/exporters/html.js index 06bea91..f423e01 100644 --- a/exporters/html.js +++ b/exporters/html.js @@ -7,7 +7,7 @@ const { highlightCode } = require('../core/code-highlight'); const { encodePng } = require('../core/png'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText, flowchartImageKey, flowchartAlt } = require('./common'); const { anchorFor, tocEntries, guideMetaLines } = require('./document-layout'); -const { withBrand, imageDataUri, cssFontStack, textOn } = require('./branding'); +const { withBrand, imageDataUri, cssFontStack, textOn, screenshotOptions } = require('./branding'); /** * HTML exporters. Both variants are fully self-contained single files: @@ -147,17 +147,26 @@ function renderStep(step, ast, images, tpl, { rich = false } = {}) { }; const blocks = (list) => list.map((tb) => (tb.kind === 'flowchart' ? chartHtml() : blockHtml(tb, ast))).join('\n'); const parts = []; - parts.push(blocks(groups.beforeDescription)); - if (step.descriptionHtml) parts.push(`
${stepLinkRewrite(step.descriptionHtml, ast)}
`); - parts.push(blocks(groups.afterDescription)); - parts.push(blocks(groups.beforeImage)); - const img = images.get(step.stepId); - if (img && tpl.includeImages) { + const img = tpl.includeImages ? images.get(step.stepId) : null; + const desc = step.descriptionHtml ? `
${stepLinkRewrite(step.descriptionHtml, ast)}
` : ''; + let figure = ''; + if (img) { const shot = `${escapeHtml(step.altText)}`; // A flowchart shown as the main visual: its linked shapes jump to their steps. - parts.push(`
${step.image?.flowchart ? chartLinks(shot, step.image, ast) : shot}
`); + figure = `
${step.image?.flowchart ? chartLinks(shot, step.image, ast) : shot}
`; + } + if (figure && desc && screenshotOptions(ast).layout === 'side-by-side') { + // Screenshot on the left, description on the right. Callouts keep their + // place: before the image above it, after the description under it. + parts.push(blocks(groups.beforeDescription)); + const media = [blocks(groups.beforeImage), figure].filter(Boolean).join(''); + const text = [desc, blocks(groups.afterDescription)].filter(Boolean).join(''); + parts.push(`
${media}
${text}
`); + parts.push(blocks(groups.afterImage)); + } else { + parts.push(blocks(groups.beforeDescription), desc, blocks(groups.afterDescription)); + parts.push(blocks(groups.beforeImage), figure, blocks(groups.afterImage)); } - parts.push(blocks(groups.afterImage)); for (const block of groups.rest) { if (block.kind === 'text') parts.push(blockHtml(block, ast)); else if (block.kind === 'code') parts.push(codeHtml(block, ast)); @@ -364,7 +373,24 @@ function brandCss(brand) { const scheme = brand.html.colorScheme === 'auto' ? `${DARK_CSS}@media (prefers-color-scheme: light) { :root { --text: ${text}; } }` : `:root { color-scheme: light; --text: ${text}; }`; - return `\n${faces.join('\n')}\n${vars}\n${scheme}\n${BRAND_CSS}${styleText(brand.html.css)}\n`; + return `\n${faces.join('\n')}\n${vars}\n${scheme}\n${BRAND_CSS}${screenshotCss(brand)}${styleText(brand.html.css)}\n`; +} + +/** Screenshot alignment, border color and side-by-side steps from the brand profile. */ +function screenshotCss(brand) { + const { align, border, borderColor, layout } = screenshotOptions({ branding: brand }); + const rules = []; + // text-align moves a linked flowchart's inline-block wrapper; the margins move a plain image. + if (align === 'center') rules.push('.shot { text-align: center; }', '.shot img { margin-left: auto; margin-right: auto; }'); + if (align === 'right') rules.push('.shot { text-align: right; }', '.shot img { margin-left: auto; margin-right: 0; }'); + if (border) rules.push(`.shot img { border: 1px solid ${borderColor}; }`); + if (layout === 'side-by-side') { + rules.push('.step-split { display: grid; grid-template-columns: minmax(0, 11fr) minmax(0, 9fr); gap: 24px; align-items: start; margin: 16px 0 20px; }'); + rules.push('.step-split .shot { margin: 0; }'); + rules.push('.split-text > .desc { margin-top: 0; }'); + rules.push('@media (max-width: 720px) { .step-split { grid-template-columns: minmax(0, 1fr); } }'); + } + return rules.length ? `${rules.join('\n')}\n` : ''; } const RICH_CSS = ` diff --git a/exporters/pdf.js b/exporters/pdf.js index a3695c4..3038e25 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -10,7 +10,7 @@ const { htmlToBlocks } = require('../core/htmlblocks'); const { standardTextWidth } = require('../core/pdf-metrics'); const { highlightCode } = require('../core/code-highlight'); const { guideMetaLines } = require('./document-layout'); -const { withBrand, brandRaster, fitSize, fontKind, textOn, hexToRgb } = require('./branding'); +const { withBrand, brandRaster, fitSize, fontKind, textOn, hexToRgb, screenshotOptions } = require('./branding'); const LIST_INDENT = 14; const QUOTE_INDENT = 10; @@ -320,6 +320,12 @@ function exportPdf(ast, outDir, template = {}, { fallbackFonts = null } = {}) { const usableW = size.width - 2 * M; const usableH = size.height - 2 * M; const images = tpl.includeImages ? renderAllImages(ast) : new Map(); + // Side by side: the screenshot takes the left 55%, the description the rest. + const shots = screenshotOptions(ast); + const SPLIT_GAP = 16; + const splitImageW = Math.round(usableW * 0.55); + const splitTextIndent = splitImageW + SPLIT_GAP; + const sideBySide = (step) => shots.layout === 'side-by-side' && Boolean(step.descriptionHtml) && images.has(step.stepId); let y = M; // Never start a page break when already at the top of a page — an item @@ -395,27 +401,28 @@ function exportPdf(ast, outDir, template = {}, { fallbackFonts = null } = {}) { }; /** Height a callout block (emitBlock) will occupy, including its trailing gap. */ - const measureBlock = (tb) => { + const measureBlock = (tb, width = usableW) => { const { height: bodyH } = tb.descriptionHtml - ? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5, bodyFonts) + ? layoutDescription(pdf, tb.descriptionHtml, width - 18, 9.5, bodyFonts) : { height: 0 }; return 16 + bodyH + 6; }; /** Height an image will occupy on the page, including its trailing gap. */ - const measureImage = (key) => { - const box = imageBox(images.get(key), key); + const measureImage = (key, maxW = usableW) => { + const box = imageBox(images.get(key), key, maxW); return box ? box.h + 10 : 0; }; /** - * The size an image is drawn at: page width, capped by the height ratio. - * A flowchart block (drawn at 2x) is never shown larger than its own size. + * The size an image is drawn at: the width it has (the page, or its + * column), capped by the height ratio. A flowchart block (drawn at 2x) is + * never shown larger than its own size. */ - function imageBox(img, key) { + function imageBox(img, key, maxW = usableW) { if (!img) return null; const maxH = usableH * tpl.imageMaxHeightRatio; - let w = String(key).startsWith('flowchart:') ? Math.min(usableW, img.width * 0.375) : usableW; + let w = String(key).startsWith('flowchart:') ? Math.min(maxW, img.width * 0.375) : maxW; let h = (img.height / img.width) * w; if (h > maxH) { h = maxH; w = (img.width / img.height) * h; } return { w, h }; @@ -472,6 +479,22 @@ function exportPdf(ast, outDir, template = {}, { fallbackFonts = null } = {}) { * the trailing gap, used to decide whether the whole step fits on a * fresh page. */ + /** Height of callout and flowchart blocks drawn `width` wide. */ + const measureBlocks = (step, list, width = usableW) => list.reduce((sum, tb) => sum + + (tb.kind === 'flowchart' ? measureImage(flowchartImageKey(step.stepId), width) : measureBlock(tb, width)), 0); + + /** + * Height of a side-by-side step's columns. Left: callouts placed before the + * image, then the screenshot. Right: the description, then callouts placed + * after it. + */ + const splitHeight = (step) => { + const { beforeImage, afterDescription } = stepContentGroups(step); + const textW = usableW - splitTextIndent; + return Math.max(measureBlocks(step, beforeImage, splitImageW) + measureImage(step.stepId, splitImageW), + layoutDescription(pdf, step.descriptionHtml, textW, 10.5, bodyFonts).height + 10 + measureBlocks(step, afterDescription, textW)); + }; + const measureStep = (step) => { const headSize = step.depth > 0 ? 12 : 14; const titleText = `${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`; @@ -487,11 +510,15 @@ function exportPdf(ast, outDir, template = {}, { fallbackFonts = null } = {}) { afterImage, rest, } = stepContentGroups(step); - for (const tb of [...beforeTitle, ...afterTitle, ...beforeDescription, ...afterDescription, ...beforeImage, ...afterImage]) { - head += tb.kind === 'flowchart' ? measureImage(flowchartImageKey(step.stepId)) : measureBlock(tb); + const measure = (list) => measureBlocks(step, list); + if (sideBySide(step)) { + head += measure([...beforeTitle, ...afterTitle, ...beforeDescription, ...afterImage]); + head += splitHeight(step); + } else { + head += measure([...beforeTitle, ...afterTitle, ...beforeDescription, ...afterDescription, ...beforeImage, ...afterImage]); + if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5, bodyFonts).height; + head += measureImage(step.stepId); } - if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5, bodyFonts).height; - head += measureImage(step.stepId); let restHeight = 0; for (const block of rest) { @@ -628,22 +655,27 @@ function exportPdf(ast, outDir, template = {}, { fallbackFonts = null } = {}) { afterImage, rest, } = stepContentGroups(step); - const drawImage = (key) => { + // An image in a space `width` wide starting at `left`, placed by the + // brand's alignment; screenshots get the brand's border. + const drawImage = (key, { left = M, width = usableW } = {}) => { const img = images.get(key); - const box = imageBox(img, key); + const box = imageBox(img, key, width); if (!box) return; ensure(box.h + 6); - pdf.image(img, M, y, box.w, box.h); + const free = width - box.w; + const x = left + (shots.align === 'center' ? free / 2 : shots.align === 'right' ? free : 0); + pdf.image(img, x, y, box.w, box.h); + if (shots.border && key === step.stepId) pdf.rect(x, y, box.w, box.h, { stroke: hexToRgb(shots.borderColor), lineWidth: 0.75 }); // A flowchart's shapes linked to a step jump to it. const chart = key === step.stepId ? (step.image?.flowchart ? step.image : null) : step.flowchartImage; for (const link of chart?.links || []) { const target = tocTargets.get(link.stepId); - if (target) pdf.linkRect(M + link.x * box.w, y + link.y * box.h, link.w * box.w, link.h * box.h, target); + if (target) pdf.linkRect(x + link.x * box.w, y + link.y * box.h, link.w * box.w, link.h * box.h, target); } y += box.h + 10; }; // Text blocks and the flowchart block, at their positions. - const emit = (tb) => (tb.kind === 'flowchart' ? drawImage(flowchartImageKey(step.stepId)) : emitBlock(tb)); + const emit = (tb, column) => (tb.kind === 'flowchart' ? drawImage(flowchartImageKey(step.stepId), column) : emitBlock(tb, column)); for (const tb of beforeTitle) emit(tb); const headSize = step.depth > 0 ? 12 : 14; writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: headFonts.bold }); @@ -651,12 +683,33 @@ function exportPdf(ast, outDir, template = {}, { fallbackFonts = null } = {}) { y += 8; for (const tb of afterTitle) emit(tb); - for (const tb of beforeDescription) emit(tb); - if (step.descriptionHtml) writeDescription(step.descriptionHtml); - for (const tb of afterDescription) emit(tb); - for (const tb of beforeImage) emit(tb); - drawImage(step.stepId); - for (const tb of afterImage) emit(tb); + if (sideBySide(step)) { + // Screenshot on the left, description on the right. Callouts keep their + // place: before the image above it, after the description under it. + for (const tb of beforeDescription) emit(tb); + const leftColumn = { left: M, width: splitImageW }; + const rightColumn = { left: M + splitTextIndent, width: usableW - splitTextIndent }; + ensure(splitHeight(step)); // any page break comes before both columns + const top = y; + const page = pdf.pages.length; + for (const tb of beforeImage) emit(tb, leftColumn); + drawImage(step.stepId, leftColumn); + const leftBottom = y; + // A left column taller than a page leaves the text to continue below it. + if (pdf.pages.length === page) y = top; + writeDescription(step.descriptionHtml, { indent: splitTextIndent }); + y += 10; + for (const tb of afterDescription) emit(tb, rightColumn); + if (pdf.pages.length === page) y = Math.max(leftBottom, y); + for (const tb of afterImage) emit(tb); + } else { + for (const tb of beforeDescription) emit(tb); + if (step.descriptionHtml) writeDescription(step.descriptionHtml); + for (const tb of afterDescription) emit(tb); + for (const tb of beforeImage) emit(tb); + drawImage(step.stepId); + for (const tb of afterImage) emit(tb); + } for (const block of rest) { if (block.kind === 'text') { @@ -687,27 +740,28 @@ function exportPdf(ast, outDir, template = {}, { fallbackFonts = null } = {}) { forcedFresh = totalHeight > usableH; } - function emitBlock(tb) { + /** A callout block in a space `width` wide starting at `left` (the page, or a side-by-side column). */ + function emitBlock(tb, { left = M, width = usableW } = {}) { const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; const { items, height: bodyH } = tb.descriptionHtml - ? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5, bodyFonts) + ? layoutDescription(pdf, tb.descriptionHtml, width - 18, 9.5, bodyFonts) : { items: [], height: 0 }; const blockH = 16 + bodyH; const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; ensure(blockH + 4); - pdf.rect(M, y, usableW, blockH, { fill: style.tint }); - pdf.rect(M, y, 3, blockH, { fill: style.accent }); - pdf.text(label, M + 10, y + 2, { size: 9.5, font: bodyFonts.bold, color: style.accent }); + pdf.rect(left, y, width, blockH, { fill: style.tint }); + pdf.rect(left, y, 3, blockH, { fill: style.accent }); + pdf.text(label, left + 10, y + 2, { size: 9.5, font: bodyFonts.bold, color: style.accent }); let by = y + 16; for (const item of items) { if (item.kind === 'hr') { - pdf.rect(M + 10, by + 5, item.width, 0.8, { fill: [225, 228, 232] }); + pdf.rect(left + 10, by + 5, item.width, 0.8, { fill: [225, 228, 232] }); by += 12; continue; } if (item.kind === 'table') { for (const row of item.rows) { - drawTableRow(pdf, item, row, M + 10 + item.indent, by, { color: ink, linkColor }); + drawTableRow(pdf, item, row, left + 10 + item.indent, by, { color: ink, linkColor }); by += row.height; } by += 6; @@ -715,7 +769,7 @@ function exportPdf(ast, outDir, template = {}, { fallbackFonts = null } = {}) { } by += item.space || 0; item.lines.forEach((line, idx) => { - const textX = M + 10 + item.indent; + const textX = left + 10 + item.indent; if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, by, { size: item.size, font: bodyFonts.regular, color: ink }); const parts = line.map((word) => ({ text: word.text, diff --git a/tests/unit/branding-screenshots.test.js b/tests/unit/branding-screenshots.test.js new file mode 100644 index 0000000..9e79d2d --- /dev/null +++ b/tests/unit/branding-screenshots.test.js @@ -0,0 +1,203 @@ +'use strict'; + +// Brand profile screenshot options: alignment, border and side-by-side steps +// in PDF, Word and HTML. Off by default, so exports stay as they were. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const zlib = require('node:zlib'); +const { BrandingStore, normalizeProfile } = require('../../core/branding'); +const { GuideStore } = require('../../core/store'); +const { buildRenderAst } = require('../../core/renderast'); +const { exportPdf } = require('../../exporters/pdf'); +const { exportDocx } = require('../../exporters/docx'); +const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html'); +const { unzipSync } = require('../../core/zip'); +const { encodePng } = require('../../core/png'); +const raster = require('../../core/raster'); +const { createBrandTools } = require('../../core/agent-brand-tools'); +const { makeTmpDir, rmrf } = require('./helpers'); + +const NOW = new Date(Date.UTC(2026, 10, 9, 12)); + +/** + * A guide whose first step has a description and a tall screenshot (so PDF + * shows it narrower than the page), the second only a screenshot, and the + * third a description, a screenshot and callouts placed before the image and + * after the description. Returns render ASTs for the given screenshot options + * and for no branding. + */ +function fixture(t, screenshots) { + const root = makeTmpDir('brand-shots'); + t.after(() => rmrf(root)); + const store = new GuideStore(path.join(root, 'data')); + const guide = store.createGuide({ title: 'Reset a password' }); + const tall = raster.createImage(200, 600, [40, 90, 160, 255]); + store.addStep(guide.guideId, { title: 'Open Settings', descriptionHtml: '

Click the gear icon in the corner.

' }, + encodePng(tall), { width: 200, height: 600 }, { position: 0 }); + const wide = raster.createImage(320, 200, [200, 120, 40, 255]); + store.addStep(guide.guideId, { title: 'Save' }, encodePng(wide), { width: 320, height: 200 }, { position: 1 }); + store.addStep(guide.guideId, { + title: 'Confirm', + descriptionHtml: '

Choose Confirm.

', + textBlocks: [ + { position: 'after-description', level: 'warn', title: 'Check who is asking', descriptionHtml: '

Verify identity.

' }, + { position: 'before-image', level: 'info', title: 'Look here', descriptionHtml: '

The button is at the bottom.

' }, + ], + }, encodePng(wide), { width: 320, height: 200 }, { position: 2 }); + const brands = new BrandingStore(path.join(root, 'settings')); + const profile = brands.save({ name: 'Acme', screenshots }); + return { + root, + ast: buildRenderAst(store, guide.guideId, { now: NOW, branding: brands.resolve(profile.id) }), + plain: buildRenderAst(store, guide.guideId, { now: NOW }), + }; +} + +function pdfPages(file) { + const buf = fs.readFileSync(file); + const text = buf.toString('latin1'); + const out = []; + const re = /\d+ 0 obj\n<< \/Filter \/FlateDecode \/Length (\d+) >>\nstream\n/g; + let m; + while ((m = re.exec(text)) !== null) { + const start = m.index + m[0].length; + out.push(zlib.inflateSync(buf.subarray(start, start + Number(m[1]))).toString('latin1')); + } + return out; +} + +/** Every image drawn on the PDF's pages: { x, y, w, h } in points. */ +function pdfImages(file) { + return pdfPages(file).flatMap((page) => [...page.matchAll(/q ([\d.]+) 0 0 ([\d.]+) ([\d.]+) ([\d.]+) cm \/Im\d+ Do Q/g)] + .map((m) => ({ w: Number(m[1]), h: Number(m[2]), x: Number(m[3]), y: Number(m[4]) }))); +} + +const docxXml = (file) => unzipSync(fs.readFileSync(file)).find((e) => e.name === 'word/document.xml').data.toString('utf8'); + +test('screenshot options: off by default, and invalid values fall back to the defaults', () => { + assert.deepEqual(normalizeProfile({}).screenshots, { align: 'left', border: false, borderColor: '#D0D5DD', layout: 'stacked' }); + assert.deepEqual(normalizeProfile({ screenshots: { align: 'middle', border: 'yes', borderColor: 'red', layout: 'grid' } }).screenshots, + { align: 'left', border: false, borderColor: '#D0D5DD', layout: 'stacked' }); + assert.deepEqual(normalizeProfile({ screenshots: { align: 'center', border: true, borderColor: '#173f7a', layout: 'side-by-side' } }).screenshots, + { align: 'center', border: true, borderColor: '#173F7A', layout: 'side-by-side' }); +}); + +test('screenshot options: a profile with the defaults exports exactly what no options did', (t) => { + const { root, ast } = fixture(t, undefined); + const { ast: explicit } = fixture(t, { align: 'left', border: false, layout: 'stacked' }); + const pdfA = pdfPages(exportPdf(ast, path.join(root, 'a')).file); + const pdfB = pdfPages(exportPdf(explicit, path.join(root, 'b')).file); + assert.deepEqual(pdfA, pdfB); + const doc = docxXml(exportDocx(ast, path.join(root, 'docx')).file); + assert.ok(!doc.includes('') && !doc.includes(' { + const x = {}; + for (const align of ['left', 'center', 'right']) { + const { root, ast } = fixture(t, { align, border: align === 'center', borderColor: '#173F7A' }); + const file = exportPdf(ast, path.join(root, align)).file; + const shot = pdfImages(file)[0]; + x[align] = shot.x; + if (align === 'center') { + const page = pdfPages(file).find((p) => p.includes(`${shot.x.toFixed(2)} ${shot.y.toFixed(2)} cm`)); + assert.ok(page.includes(`0.090 0.247 0.478 RG 0.75 w ${shot.x.toFixed(2)} ${shot.y.toFixed(2)} ${shot.w.toFixed(2)} ${shot.h.toFixed(2)} re S`), + 'a border in the chosen color around the screenshot'); + } + } + const usable = 595.28 - 2 * x.left; // left-aligned starts at the margin + const plain = fixture(t, {}); + const shotW = pdfImages(exportPdf(plain.ast, path.join(plain.root, 'w')).file)[0].w; + assert.ok(shotW < usable - 100, 'the tall screenshot is narrower than the page'); + assert.ok(Math.abs(x.center - (x.left + (usable - shotW) / 2)) < 0.1, `centered (${x.left}, ${x.center})`); + assert.ok(Math.abs(x.right - (x.left + usable - shotW)) < 0.1, `right (${x.right})`); +}); + +test('PDF: side by side puts the screenshot left and the description right, top-aligned', (t) => { + const { root, ast } = fixture(t, { layout: 'side-by-side' }); + const file = exportPdf(ast, path.join(root, 'pdf')).file; + const [first, second] = pdfImages(file); + const usable = 595.28 - 2 * first.x; + assert.ok(first.w <= Math.round(usable * 0.55) + 0.01, 'the screenshot fits its column'); + const page = pdfPages(file).find((p) => p.includes('(gear)')); + const desc = page.match(/1 0 0 1 ([\d.]+) ([\d.]+) Tm [^\n]*?\(Click\) Tj/); + assert.ok(desc && page.includes('/Im1 Do'), 'the description is on the screenshot’s page'); + assert.ok(Math.abs(Number(desc[1]) - (first.x + Math.round(usable * 0.55) + 16)) < 0.1, 'in the right-hand column'); + assert.ok(Number(desc[1]) > first.x + first.w, 'the description starts right of the screenshot'); + const descTop = Number(desc[2]) + 10.5; + assert.ok(Math.abs(descTop - (first.y + first.h)) < 12, 'the description starts level with the screenshot'); + assert.ok(Math.abs(second.w - usable) < 0.1, 'a step without a description stays stacked, full width'); + + // Third step: "Look here" above the screenshot in the left column, the + // warning under the description in the right column. + const third = pdfImages(file)[2]; + const thirdPage = pdfPages(file).find((p) => p.includes('(Confirm.)')); + const at = (label) => thirdPage.match(new RegExp(`1 0 0 1 ([\\d.]+) ([\\d.]+) Tm [^\\n]*?\\(${label}`)); + const look = at('Note: Look'), confirm = at('Choose'), warn = at('Warning'); + assert.ok(look && confirm && warn, 'all three on the step’s page'); + assert.ok(Number(look[1]) < third.x + third.w && Number(look[2]) > third.y + third.h, 'the before-image callout is above the screenshot, in its column'); + assert.ok(Number(warn[1]) > third.x + third.w && Number(warn[2]) < Number(confirm[2]), 'the after-description callout is under the description, in the text column'); +}); + +test('Word: alignment, a picture border and a two-column table for side by side', (t) => { + const { root, ast } = fixture(t, { align: 'center', border: true, borderColor: '#173F7A', layout: 'side-by-side' }); + const doc = docxXml(exportDocx(ast, path.join(root, 'docx')).file); + assert.equal((doc.match(/<\/a:solidFill><\/a:ln>/g) || []).length, 3, 'every screenshot bordered'); + const split = doc.match(/[\s\S]*?<\/w:tbl>/); + assert.ok(split, 'side-by-side table'); + const [left, right] = [...split[0].matchAll(/([\s\S]*?)<\/w:tc>/g)].map((m) => m[1]); + assert.match(left, //); + assert.match(right, /Click the gear icon/); + assert.match(split[0], /(){6}<\/w:tblBorders>/, 'no table borders'); + assert.equal((doc.match(//g) || []).length, 2, 'the step without a description stays stacked'); + const [, third] = doc.match(/[\s\S]*?<\/w:tbl>/g); + const [media, text] = [...third.matchAll(/([\s\S]*?)<\/w:tc>/g)].map((m) => m[1]); + assert.ok(media.indexOf('Look here') < media.indexOf(''), 'a before-image callout sits above the screenshot'); + assert.ok(text.indexOf('Choose Confirm') < text.indexOf('Check who is asking'), 'an after-description callout sits under the description'); + assert.match(doc, /<\/w:pPr>/, 'stacked screenshot centered'); + + const right2 = docxXml(exportDocx(fixture(t, { align: 'right' }).ast, path.join(root, 'right')).file); + assert.match(right2, /<\/w:pPr>/); +}); + +test('HTML: alignment and border CSS, and a responsive two-column step', (t) => { + const { root, ast, plain } = fixture(t, { align: 'right', border: true, borderColor: '#173F7A', layout: 'side-by-side' }); + for (const exporter of [exportHtmlSimple, exportHtmlRich]) { + const html = fs.readFileSync(exporter(ast, path.join(root, exporter.name)).file, 'utf8'); + assert.match(html, /\.shot \{ text-align: right; \}/, 'moves a linked flowchart’s inline-block wrapper too'); + assert.match(html, /\.shot img \{ margin-left: auto; margin-right: 0; \}/); + assert.match(html, /\.shot img \{ border: 1px solid #173F7A; \}/); + assert.match(html, /\.step-split \{ display: grid;/); + assert.match(html, /@media \(max-width: 720px\) \{ \.step-split \{ grid-template-columns: minmax\(0, 1fr\); \} \}/); + assert.match(html, /
]+><\/figure><\/div>

Click the gear icon/); + assert.equal((html.match(/class="step-split"/g) || []).length, 2, 'only steps with a description are split'); + const third = html.slice(html.lastIndexOf('class="step-split"')); + const media = third.slice(third.indexOf('split-media'), third.indexOf('split-text')); + const text = third.slice(third.indexOf('split-text')); + assert.ok(media.indexOf('Look here') >= 0 && media.indexOf('Look here') < media.indexOf(' { + const root = makeTmpDir('brand-shots-agent'); + t.after(() => rmrf(root)); + class AgentError extends Error {} + const tools = createBrandTools({ + settingsDir: root, requireEnabled: () => {}, agentSettings: () => ({ branding: true }), AgentError, + text: (value) => ({ type: 'text', text: JSON.stringify(value) }), + }); + const call = async (name, args) => JSON.parse((await tools.find((tool) => tool.name === name).handler(args)).content[0].text); + const created = await call('create_brand_profile', { name: 'Acme', screenshots: { align: 'center', border: true, border_color: '#112233' } }); + assert.deepEqual(created.screenshots, { align: 'center', border: true, border_color: '#112233', layout: 'stacked' }); + const updated = await call('update_brand_profile', { profile_id: created.profile_id, screenshots: { layout: 'side-by-side' } }); + assert.deepEqual(updated.screenshots, { align: 'center', border: true, border_color: '#112233', layout: 'side-by-side' }); + await assert.rejects(call('update_brand_profile', { profile_id: created.profile_id, screenshots: { align: 'middle' } }), AgentError); +}); diff --git a/tests/unit/ui-translations.test.js b/tests/unit/ui-translations.test.js index 06c53db..7b0739f 100644 --- a/tests/unit/ui-translations.test.js +++ b/tests/unit/ui-translations.test.js @@ -75,7 +75,7 @@ test('cloud services, GitHub sharing and branding text follow the interface lang const es = load('es', ['branding.js']); const support = vm.runInContext('BRAND_SUPPORT', es); assert.deepEqual([...support[0]], ['HTML (simple y enriquecido)', - 'Logotipo, banner, colores, fuentes (las fuentes subidas se incrustan), estilo de portada, texto del pie de página y tu CSS.']); + 'Logotipo, banner, colores, fuentes (las fuentes subidas se incrustan), estilo de portada, texto del pie de página, alineación, borde y diseño de las capturas de pantalla, y tu CSS.']); assert.equal(support[2][0], 'PDF', 'format names stay as they are'); });