From bb2e071d63f9db3e033b20e7bbfdee5f760a58b4 Mon Sep 17 00:00:00 2001 From: Slavov88 Date: Tue, 10 Nov 2026 16:20:58 +0200 Subject: [PATCH 1/2] Add research poster projects to Overroot --- README.md | 3 + apps/server/src/poster-template-files.ts | 36 +++++++++++ apps/server/src/posters.ts | 62 +++++++++++++++++++ apps/server/src/project-store.ts | 7 ++- apps/server/src/routes/project-routes.ts | 5 +- apps/server/src/templates.ts | 12 +++- apps/server/tests/api.test.ts | 23 +++++++ apps/server/tests/posters.test.ts | 27 ++++++++ .../src/components/DashboardDialogs.test.tsx | 12 ++++ apps/web/src/components/DashboardDialogs.tsx | 24 ++++++- apps/web/src/components/PdfViewer.tsx | 21 +++++-- apps/web/src/lib/api.ts | 5 +- apps/web/src/lib/pdf-raster.test.ts | 13 ++++ apps/web/src/lib/pdf-raster.ts | 10 +++ apps/web/src/pages/DashboardPage.tsx | 6 +- apps/web/src/pages/WorkspacePage.tsx | 4 +- apps/web/src/styles.css | 6 ++ apps/web/src/types.ts | 11 ++++ docker/tectonic/Dockerfile | 2 +- docs/OVERPOSTER-LICENSE.txt | 21 +++++++ docs/POSTERS.md | 9 +++ 21 files changed, 299 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/poster-template-files.ts create mode 100644 apps/server/src/posters.ts create mode 100644 apps/server/tests/posters.test.ts create mode 100644 apps/web/src/lib/pdf-raster.test.ts create mode 100644 apps/web/src/lib/pdf-raster.ts create mode 100644 docs/OVERPOSTER-LICENSE.txt create mode 100644 docs/POSTERS.md diff --git a/README.md b/README.md index 0c08ed1..fc78458 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Overroot is independent and unaffiliated with Overleaf or Underleaf.ai. - PDF viewer that preserves page, zoom, and exact scroll position after recompilation - One-click PDF-only mode with `Escape` to return - Article, report, presentation, and blank templates; file uploads and ZIP import/export +- Academic Classic, Modern ML, and Visual Results poster templates with A0, A1, and custom print sizes - Light, dark, and system appearance across desktop and mobile layouts - Project-scoped, revocable credentials for external agents - SQLite persistence plus an online backup command with bounded snapshot retention @@ -86,6 +87,8 @@ exports always use the original project files. Open a `.tex` file and select **Compile** to flush the current collaborative editor state and run Tectonic on that file. Every editor can compile the file they opened; no owner-selected main file is needed. Successful output appears in the PDF pane for every connected collaborator. A completely empty document body has no printable page, so add content between `\begin{document}` and `\end{document}` before compiling a blank template. +For a poster, choose one of the poster templates at project creation and set its size, orientation, columns, and heading metadata. Edit the modular `sections/*.tex` files in **Files**, compile `poster.tex`, and review the PDF's measured page count and physical dimensions before downloading. See [poster guide](docs/POSTERS.md). + ## External agent access Overroot does not embed an AI model or chat interface. A project owner can authorize an external coding agent to edit the project directly: diff --git a/apps/server/src/poster-template-files.ts b/apps/server/src/poster-template-files.ts new file mode 100644 index 0000000..8448407 --- /dev/null +++ b/apps/server/src/poster-template-files.ts @@ -0,0 +1,36 @@ +// Adapted from Overposter (MIT); see docs/POSTERS.md. +export const posterTemplateFiles: Record> = { + "academic-classic": { + "poster.tex": "\\documentclass{article}\n\\input{config.tex}\n\\usepackage[paperwidth=\\PosterWidth mm,paperheight=\\PosterHeight mm,margin=25mm]{geometry}\n\\usepackage{xcolor}\n\\definecolor{accent}{HTML}{23496C}\n\\definecolor{ink}{HTML}{172B40}\n\\input{poster-layout.tex}\n\\newcommand{\\PosterHeading}[1]{{\\color{accent}\\fontsize{\\PosterHeadingSize pt}{42pt}\\selectfont\\bfseries #1}\\par\\hrule height 1.2pt}\n\\begin{document}\n\\IfFileExists{assets/logo.pdf}{\\noindent\\includegraphics[height=25mm]{assets/logo.pdf}\\par\\vspace{4mm}}{}\n\\noindent\\begin{minipage}[c]{\\linewidth}\n{\\color{accent}\\fontsize{\\PosterTitleSize pt}{70pt}\\selectfont\\bfseries\\PosterTitle\\par}\n\\vspace{3mm}{\\fontsize{28}{35}\\selectfont\\PosterSubtitle\\par}\n\\vspace{5mm}\\PosterMetadata\n\\end{minipage}\n\\par\\vspace{5mm}{\\color{accent}\\hrule height 3pt}\\vspace{7mm}\n\\PosterGrid{\n\\PosterBlock{Motivation and question}{\\input{sections/introduction.tex}}\n\\PosterBlock{Method}{\\input{sections/methodology.tex}}\n\\PosterBlock{Study design}{\\input{sections/study.tex}}\n\\PosterBlock{Results}{\\input{sections/results.tex}}\n\\PosterBlock{Interpretation}{\\input{sections/conclusions.tex}}\n\\PosterBlock{References}{\\input{sections/references.tex}}\n\\PosterContactBlock\n}\n\\end{document}\n", + "references.bib": "% Add verified BibTeX entries.\n", + "sections/conclusions.tex": "Summarize the supported finding, limitations, and next step.\n", + "sections/introduction.tex": "State the problem, why it matters, and a precise research question. Replace this guidance with your own evidence and citations.\n", + "sections/methodology.tex": "Explain the method and its assumptions. Equations can be included, for example $\\hat y=f_{\\theta}(x)$.\n", + "sections/references.tex": "Add verified references here. Bibliography data can be stored in \\texttt{references.bib}.\n", + "sections/results.tex": "Add a table, uncertainty intervals, and a visual comparison. Clearly label any illustrative numbers.\n", + "sections/study.tex": "Describe the dataset, baselines, and evaluation protocol. Add a vector diagram in \\texttt{figures/} and include it with \\texttt{\\textbackslash includegraphics}.\n", + "poster-layout.tex": "\\usepackage{amsmath,amssymb,graphicx,xcolor,tikz,multicol,qrcode,hyperref}\n\\hypersetup{colorlinks=true,urlcolor=accent}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0pt}\n\\setlength{\\columnsep}{12mm}\n\\newcommand{\\PosterBody}{\\fontsize{\\PosterBodySize pt}{\\PosterBodyLeading pt}\\selectfont}\n\\newcommand{\\PosterMetadata}{%\n \\ifx\\PosterAuthors\\empty\\else\n {\\fontsize{30}{38}\\selectfont\\PosterAuthors\\par}\n \\fi\n \\ifx\\PosterAffiliation\\empty\\else\n \\vspace{3mm}{\\fontsize{22}{29}\\selectfont\\PosterAffiliation\\par}\n \\fi\n}\n\\newcommand{\\PosterBlock}[2]{%\n \\par\\vspace{5mm}\\noindent\n \\begin{minipage}{\\linewidth}\n \\PosterHeading{#1}\\par\\vspace{3mm}\n {\\PosterBody #2\\par}\\vspace{3mm}\n \\end{minipage}\\par\n}\n\\newcommand{\\PosterGrid}[1]{\\begin{multicols}{\\PosterColumns}#1\\end{multicols}}\n\\newcommand{\\PosterContactBlock}{%\n \\ifx\\PosterPaperURL\\empty\\else\n \\par\\vspace{6mm}\n \\noindent\\begin{minipage}[c]{39mm}\\qrcode[height=35mm]{\\PosterPaperURL}\\end{minipage}%\n \\hspace{5mm}\\begin{minipage}[c]{\\dimexpr\\linewidth-46mm\\relax}\n {\\fontsize{21}{28}\\selectfont Paper and supplementary material\\par\n \\url{\\PosterPaperURL}}\n \\end{minipage}\\par\n \\fi\n \\ifx\\PosterContact\\empty\\else\n \\par\\vspace{4mm}{\\fontsize{21}{28}\\selectfont\\PosterContact\\par}\n \\fi\n}\n" + }, + "modern-ml": { + "poster.tex": "\\documentclass{article}\n\\input{config.tex}\n\\usepackage[paperwidth=\\PosterWidth mm,paperheight=\\PosterHeight mm,margin=24mm]{geometry}\n\\usepackage{xcolor}\n\\definecolor{accent}{HTML}{0D766E}\n\\definecolor{ink}{HTML}{153448}\n\\definecolor{pale}{HTML}{E8F3F0}\n\\input{poster-layout.tex}\n\\newcommand{\\PosterHeading}[1]{{\\color{accent}\\fontsize{\\PosterHeadingSize pt}{42pt}\\selectfont\\bfseries #1}\\par}\n\\begin{document}\n\\noindent\\colorbox{ink}{\\begin{minipage}{\\dimexpr\\linewidth-2\\fboxsep\\relax}\n\\vspace{8mm}\\hspace{8mm}\\begin{minipage}[c]{0.80\\linewidth}\n{\\color{white}\\fontsize{\\PosterTitleSize pt}{70pt}\\selectfont\\bfseries\\PosterTitle\\par}\n\\vspace{2mm}{\\color{white}\\fontsize{27}{34}\\selectfont\\PosterSubtitle\\par}\n\\vspace{4mm}{\\color{white}\\PosterMetadata}\n\\end{minipage}\n\\IfFileExists{assets/logo.pdf}{\\hfill\\begin{minipage}[c]{0.13\\linewidth}\\includegraphics[width=\\linewidth]{assets/logo.pdf}\\end{minipage}}{}\n\\vspace{8mm}\n\\end{minipage}}\\par\\vspace{8mm}\n\\noindent\\colorbox{pale}{\\begin{minipage}{\\dimexpr\\linewidth-2\\fboxsep\\relax}\\vspace{5mm}\\hspace{7mm}\\begin{minipage}{0.94\\linewidth}\n{\\color{ink}\\fontsize{25}{32}\\selectfont\\bfseries Key takeaway\\par}\n\\vspace{2mm}{\\PosterBody\\input{sections/conclusions.tex}\\par}\n\\end{minipage}\\vspace{5mm}\\end{minipage}}\\par\\vspace{5mm}\n\\PosterGrid{\n\\PosterBlock{The question}{\\input{sections/introduction.tex}}\n\\PosterBlock{Approach}{\\input{sections/methodology.tex}}\n\\PosterBlock{Experimental setup}{\\input{sections/study.tex}}\n\\PosterBlock{Main result}{\\input{sections/results.tex}}\n\\PosterBlock{Sources}{\\input{sections/references.tex}}\n\\PosterContactBlock\n}\n\\end{document}\n", + "references.bib": "% Add verified BibTeX entries.\n", + "sections/conclusions.tex": "Summarize the supported finding, limitations, and next step.\n", + "sections/introduction.tex": "State the problem, why it matters, and a precise research question. Replace this guidance with your own evidence and citations.\n", + "sections/methodology.tex": "Explain the method and its assumptions. Equations can be included, for example $\\hat y=f_{\\theta}(x)$.\n", + "sections/references.tex": "Add verified references here. Bibliography data can be stored in \\texttt{references.bib}.\n", + "sections/results.tex": "Add a table, uncertainty intervals, and a visual comparison. Clearly label any illustrative numbers.\n", + "sections/study.tex": "Describe the dataset, baselines, and evaluation protocol. Add a vector diagram in \\texttt{figures/} and include it with \\texttt{\\textbackslash includegraphics}.\n", + "poster-layout.tex": "\\usepackage{amsmath,amssymb,graphicx,xcolor,tikz,multicol,qrcode,hyperref}\n\\hypersetup{colorlinks=true,urlcolor=accent}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0pt}\n\\setlength{\\columnsep}{12mm}\n\\newcommand{\\PosterBody}{\\fontsize{\\PosterBodySize pt}{\\PosterBodyLeading pt}\\selectfont}\n\\newcommand{\\PosterMetadata}{%\n \\ifx\\PosterAuthors\\empty\\else\n {\\fontsize{30}{38}\\selectfont\\PosterAuthors\\par}\n \\fi\n \\ifx\\PosterAffiliation\\empty\\else\n \\vspace{3mm}{\\fontsize{22}{29}\\selectfont\\PosterAffiliation\\par}\n \\fi\n}\n\\newcommand{\\PosterBlock}[2]{%\n \\par\\vspace{5mm}\\noindent\n \\begin{minipage}{\\linewidth}\n \\PosterHeading{#1}\\par\\vspace{3mm}\n {\\PosterBody #2\\par}\\vspace{3mm}\n \\end{minipage}\\par\n}\n\\newcommand{\\PosterGrid}[1]{\\begin{multicols}{\\PosterColumns}#1\\end{multicols}}\n\\newcommand{\\PosterContactBlock}{%\n \\ifx\\PosterPaperURL\\empty\\else\n \\par\\vspace{6mm}\n \\noindent\\begin{minipage}[c]{39mm}\\qrcode[height=35mm]{\\PosterPaperURL}\\end{minipage}%\n \\hspace{5mm}\\begin{minipage}[c]{\\dimexpr\\linewidth-46mm\\relax}\n {\\fontsize{21}{28}\\selectfont Paper and supplementary material\\par\n \\url{\\PosterPaperURL}}\n \\end{minipage}\\par\n \\fi\n \\ifx\\PosterContact\\empty\\else\n \\par\\vspace{4mm}{\\fontsize{21}{28}\\selectfont\\PosterContact\\par}\n \\fi\n}\n" + }, + "visual-results": { + "poster.tex": "\\documentclass{article}\n\\input{config.tex}\n\\usepackage[paperwidth=\\PosterWidth mm,paperheight=\\PosterHeight mm,margin=24mm]{geometry}\n\\usepackage{xcolor}\n\\definecolor{accent}{HTML}{A54431}\n\\definecolor{ink}{HTML}{25323A}\n\\definecolor{pale}{HTML}{F7EDE8}\n\\input{poster-layout.tex}\n\\newcommand{\\PosterHeading}[1]{{\\color{accent}\\fontsize{\\PosterHeadingSize pt}{42pt}\\selectfont\\bfseries #1}\\par}\n\\begin{document}\n{\\color{accent}\\hrule height 7pt}\\vspace{6mm}\n\\IfFileExists{assets/logo.pdf}{\\noindent\\includegraphics[height=25mm]{assets/logo.pdf}\\par\\vspace{4mm}}{}\n\\noindent\\begin{minipage}[c]{\\linewidth}\n{\\color{ink}\\fontsize{\\PosterTitleSize pt}{70pt}\\selectfont\\bfseries\\PosterTitle\\par}\n\\vspace{3mm}{\\fontsize{27}{34}\\selectfont\\PosterSubtitle\\par}\n\\vspace{5mm}\\PosterMetadata\n\\end{minipage}\n\\par\\vspace{8mm}\n\\noindent\\colorbox{pale}{\\begin{minipage}{\\dimexpr\\linewidth-2\\fboxsep\\relax}\\vspace{5mm}\\hspace{7mm}\\begin{minipage}{0.94\\linewidth}\n\\PosterHeading{Visual evidence}\\vspace{3mm}{\\PosterBody\\input{sections/study.tex}\\par}\n\\end{minipage}\\vspace{5mm}\\end{minipage}}\\par\\vspace{8mm}\n\\PosterGrid{\n\\PosterBlock{Question}{\\input{sections/introduction.tex}}\n\\PosterBlock{Method}{\\input{sections/methodology.tex}}\n\\PosterBlock{Measured outcomes}{\\input{sections/results.tex}}\n\\PosterBlock{What to remember}{\\input{sections/conclusions.tex}}\n\\PosterBlock{References}{\\input{sections/references.tex}}\n\\PosterContactBlock\n}\n\\end{document}\n", + "references.bib": "% Add verified BibTeX entries.\n", + "sections/conclusions.tex": "Summarize the supported finding, limitations, and next step.\n", + "sections/introduction.tex": "State the problem, why it matters, and a precise research question. Replace this guidance with your own evidence and citations.\n", + "sections/methodology.tex": "Explain the method and its assumptions. Equations can be included, for example $\\hat y=f_{\\theta}(x)$.\n", + "sections/references.tex": "Add verified references here. Bibliography data can be stored in \\texttt{references.bib}.\n", + "sections/results.tex": "Add a table, uncertainty intervals, and a visual comparison. Clearly label any illustrative numbers.\n", + "sections/study.tex": "Describe the dataset, baselines, and evaluation protocol. Add a vector diagram in \\texttt{figures/} and include it with \\texttt{\\textbackslash includegraphics}.\n", + "poster-layout.tex": "\\usepackage{amsmath,amssymb,graphicx,xcolor,tikz,multicol,qrcode,hyperref}\n\\hypersetup{colorlinks=true,urlcolor=accent}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0pt}\n\\setlength{\\columnsep}{12mm}\n\\newcommand{\\PosterBody}{\\fontsize{\\PosterBodySize pt}{\\PosterBodyLeading pt}\\selectfont}\n\\newcommand{\\PosterMetadata}{%\n \\ifx\\PosterAuthors\\empty\\else\n {\\fontsize{30}{38}\\selectfont\\PosterAuthors\\par}\n \\fi\n \\ifx\\PosterAffiliation\\empty\\else\n \\vspace{3mm}{\\fontsize{22}{29}\\selectfont\\PosterAffiliation\\par}\n \\fi\n}\n\\newcommand{\\PosterBlock}[2]{%\n \\par\\vspace{5mm}\\noindent\n \\begin{minipage}{\\linewidth}\n \\PosterHeading{#1}\\par\\vspace{3mm}\n {\\PosterBody #2\\par}\\vspace{3mm}\n \\end{minipage}\\par\n}\n\\newcommand{\\PosterGrid}[1]{\\begin{multicols}{\\PosterColumns}#1\\end{multicols}}\n\\newcommand{\\PosterContactBlock}{%\n \\ifx\\PosterPaperURL\\empty\\else\n \\par\\vspace{6mm}\n \\noindent\\begin{minipage}[c]{39mm}\\qrcode[height=35mm]{\\PosterPaperURL}\\end{minipage}%\n \\hspace{5mm}\\begin{minipage}[c]{\\dimexpr\\linewidth-46mm\\relax}\n {\\fontsize{21}{28}\\selectfont Paper and supplementary material\\par\n \\url{\\PosterPaperURL}}\n \\end{minipage}\\par\n \\fi\n \\ifx\\PosterContact\\empty\\else\n \\par\\vspace{4mm}{\\fontsize{21}{28}\\selectfont\\PosterContact\\par}\n \\fi\n}\n" + } +}; diff --git a/apps/server/src/posters.ts b/apps/server/src/posters.ts new file mode 100644 index 0000000..226f8c9 --- /dev/null +++ b/apps/server/src/posters.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import { posterTemplateFiles } from './poster-template-files.js'; + +export const posterThemes = ['academic-classic', 'modern-ml', 'visual-results'] as const; +export const posterOptionsSchema = z.object({ + subtitle: z.string().trim().max(240).default(''), + authors: z.string().trim().max(500).default(''), + affiliation: z.string().trim().max(500).default(''), + size: z.enum(['A0', 'A1', 'custom']).default('A1'), + orientation: z.enum(['landscape', 'portrait']).default('landscape'), + widthMm: z.number().int().min(200).max(3000).optional(), + heightMm: z.number().int().min(200).max(3000).optional(), + columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(2) +}).superRefine((value, context) => { + if (value.size === 'custom' && (!value.widthMm || !value.heightMm)) { + context.addIssue({ code: 'custom', message: 'Custom posters need a width and height in millimetres' }); + } +}); + +export type PosterOptions = z.infer; + +export function isPosterTheme(value: string): value is typeof posterThemes[number] { + return posterThemes.some((theme) => theme === value); +} + +export function posterDimensions(options: PosterOptions): { widthMm: number; heightMm: number } { + if (options.size === 'custom') return { widthMm: options.widthMm!, heightMm: options.heightMm! }; + const short = options.size === 'A0' ? 841 : 594; + const long = options.size === 'A0' ? 1189 : 841; + return options.orientation === 'landscape' + ? { widthMm: long, heightMm: short } + : { widthMm: short, heightMm: long }; +} + +function texEscape(value: string): string { + const chars: Record = { + '\\': '\\textbackslash{}', '{': '\\{', '}': '\\}', '%': '\\%', '$': '\\$', + '&': '\\&', '#': '\\#', '_': '\\_', '^': '\\textasciicircum{}', '~': '\\textasciitilde{}' + }; + return value.replace(/[\\{}%$&#_^~]/g, (character) => chars[character]!); +} + +export function posterFiles(theme: typeof posterThemes[number], title: string, input?: PosterOptions): Record { + const options = posterOptionsSchema.parse(input ?? {}); + const { widthMm, heightMm } = posterDimensions(options); + const source = posterTemplateFiles[theme]!; + const config = [ + `% Poster settings. Edit this file to change the print size and metadata.`, + `\\def\\PosterWidth{${widthMm}}`, + `\\def\\PosterHeight{${heightMm}}`, + `\\def\\PosterTitle{${texEscape(title)}}`, + `\\def\\PosterSubtitle{${texEscape(options.subtitle)}}`, + `\\def\\PosterAuthors{${texEscape(options.authors)}}`, + `\\def\\PosterAffiliation{${texEscape(options.affiliation)}}`, + `\\def\\PosterContact{}`, `\\def\\PosterPaperURL{}`, + `\\def\\PosterColumns{${options.columns}}`, + `\\def\\PosterBodySize{27}`, `\\def\\PosterBodyLeading{35}`, + `\\def\\PosterHeadingSize{40}`, `\\def\\PosterTitleSize{68}`, + '' + ].join('\n'); + return { ...source, 'config.tex': config }; +} diff --git a/apps/server/src/project-store.ts b/apps/server/src/project-store.ts index aaed6a1..d1e72ef 100644 --- a/apps/server/src/project-store.ts +++ b/apps/server/src/project-store.ts @@ -4,6 +4,7 @@ import * as Y from 'yjs'; import type { Database } from './database.js'; import { HttpError } from './errors.js'; import { isTextMime, normalizeProjectPath } from './paths.js'; +import { isPosterTheme, posterFiles, type PosterOptions } from './posters.js'; import { fileRevision, randomPublicId } from './security.js'; import { findTemplate } from './templates.js'; import type { ActorIdentity, FileRecord, ProjectRecord, ProjectRole } from './types.js'; @@ -18,10 +19,12 @@ export interface ProjectSummary extends ProjectRecord { export class ProjectStore { constructor(private readonly db: Database) {} - createProject(ownerId: string, nameInput: string, templateId = 'article'): ProjectRecord { + createProject(ownerId: string, nameInput: string, templateId = 'article', poster?: PosterOptions): ProjectRecord { const name = cleanProjectName(nameInput); const template = findTemplate(templateId); if (!template) throw new HttpError(400, 'Unknown project template', 'unknown_template'); + if (poster && !isPosterTheme(templateId)) throw new HttpError(400, 'Poster settings require a poster template', 'invalid_poster_settings'); + const initialFiles = isPosterTheme(templateId) ? posterFiles(templateId, name, poster) : template.files; const now = new Date().toISOString(); const project: ProjectRecord = { id: randomUUID(), @@ -51,7 +54,7 @@ export class ProjectStore { 'owner', now ); - for (const [path, content] of Object.entries(template.files)) { + for (const [path, content] of Object.entries(initialFiles)) { this.insertFile(project.id, path, Buffer.from(content), 'text/plain', { type: 'system', id: 'template', name: template.name }, now); } }); diff --git a/apps/server/src/routes/project-routes.ts b/apps/server/src/routes/project-routes.ts index 4b7f38d..c317bc0 100644 --- a/apps/server/src/routes/project-routes.ts +++ b/apps/server/src/routes/project-routes.ts @@ -7,6 +7,7 @@ import { config } from '../config.js'; import type { AppContext } from '../context.js'; import { HttpError } from '../errors.js'; import { projectAccess, requireOwner } from '../permissions.js'; +import { posterOptionsSchema } from '../posters.js'; import { safeUnzip } from '../safe-zip.js'; import { randomToken, sha256 } from '../security.js'; import { projectTemplates } from '../templates.js'; @@ -31,8 +32,8 @@ export function registerProjectRoutes(app: FastifyInstance, context: AppContext) app.post('/api/v1/projects', async (request, reply) => { const actor = context.auth.requireUser(request); - const input = z.object({ name: z.string(), template: z.string().default('article') }).parse(request.body); - const project = context.projects.createProject(actor.id, input.name, input.template); + const input = z.object({ name: z.string(), template: z.string().default('article'), poster: posterOptionsSchema.optional() }).parse(request.body); + const project = context.projects.createProject(actor.id, input.name, input.template, input.poster); audit(context.db, project.id, actorIdentity(actor), 'project.created', { template: input.template }); return reply.code(201).send({ project: projectDetail(context, project.public_hash, actor) }); }); diff --git a/apps/server/src/templates.ts b/apps/server/src/templates.ts index d6d729b..8058dfc 100644 --- a/apps/server/src/templates.ts +++ b/apps/server/src/templates.ts @@ -1,3 +1,6 @@ +import { posterTemplateFiles } from './poster-template-files.js'; +import { posterThemes } from './posters.js'; + export interface ProjectTemplate { id: string; name: string; @@ -88,7 +91,14 @@ Start writing here. files: { 'main.tex': '\\documentclass{article}\n\\begin{document}\n\n\\end{document}\n' } - } + }, + ...posterThemes.map((theme) => ({ + id: theme, + name: ({ 'academic-classic': 'Academic Classic poster', 'modern-ml': 'Modern ML poster', 'visual-results': 'Visual Results poster' })[theme], + description: 'A large-format research poster with modular sections and a print-ready page.', + entryFile: 'poster.tex', + files: posterTemplateFiles[theme]! + })) ]; export function findTemplate(id: string): ProjectTemplate | undefined { diff --git a/apps/server/tests/api.test.ts b/apps/server/tests/api.test.ts index 94edee5..903651f 100644 --- a/apps/server/tests/api.test.ts +++ b/apps/server/tests/api.test.ts @@ -40,6 +40,29 @@ function bearer(token: string) { } describe('Overroot API', () => { + it('creates poster projects inside the normal permission and file model', async () => { + const { app, adminToken } = harness!; + const created = await app.inject({ + method: 'POST', url: '/api/v1/projects', headers: bearer(adminToken), + payload: { name: 'Poster & Results', template: 'visual-results', poster: { + subtitle: 'Example', authors: 'A. Scientist', affiliation: 'Institute', + size: 'A0', orientation: 'portrait', columns: 3 + } } + }); + expect(created.statusCode).toBe(201); + expect(created.json().project.entryFile).toBe('poster.tex'); + const files = await app.inject({ method: 'GET', url: `/api/v1/projects/${created.json().project.hash}/files`, headers: bearer(adminToken) }); + expect(files.statusCode).toBe(200); + const paths = files.json().files.map((file: { path: string }) => file.path); + expect(paths).toContain('config.tex'); + expect(paths).toContain('poster-layout.tex'); + expect(paths).toContain('sections/results.tex'); + const invalid = await app.inject({ method: 'POST', url: '/api/v1/projects', headers: bearer(adminToken), payload: { + name: 'Invalid', template: 'visual-results', poster: { size: 'custom', widthMm: 100 } + } }); + expect(invalid.statusCode).toBe(400); + }); + it('reports the canonical public URL and API contract', async () => { const { app } = harness!; const status = await app.inject({ method: 'GET', url: '/api/v1/status' }); diff --git a/apps/server/tests/posters.test.ts b/apps/server/tests/posters.test.ts new file mode 100644 index 0000000..f77de97 --- /dev/null +++ b/apps/server/tests/posters.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { posterDimensions, posterFiles, posterOptionsSchema, posterThemes } from '../src/posters.js'; + +describe('poster project templates', () => { + it.each(posterThemes)('creates a modular %s poster with one configured entry file', (theme) => { + const files = posterFiles(theme, 'Results & Methods', { + subtitle: 'A study', authors: 'A. Researcher', affiliation: 'Example Institute', + size: 'A1', orientation: 'landscape', columns: 2 + }); + expect(files['poster.tex']).toContain('\\input{config.tex}'); + expect(files['poster-layout.tex']).toContain('\\newcommand{\\PosterGrid}'); + expect(files['sections/results.tex']).toBeTruthy(); + expect(files['config.tex']).toContain('\\def\\PosterWidth{841}'); + expect(files['config.tex']).toContain('\\def\\PosterHeight{594}'); + expect(files['config.tex']).toContain('Results \\& Methods'); + expect(files['config.tex']).not.toContain('\\write18'); + }); + + it('supports A0 portrait and bounded custom print dimensions', () => { + expect(posterDimensions(posterOptionsSchema.parse({ size: 'A0', orientation: 'portrait' }))) + .toEqual({ widthMm: 841, heightMm: 1189 }); + expect(posterDimensions(posterOptionsSchema.parse({ size: 'custom', widthMm: 1000, heightMm: 700 }))) + .toEqual({ widthMm: 1000, heightMm: 700 }); + expect(() => posterOptionsSchema.parse({ size: 'custom', widthMm: 4000, heightMm: 700 })).toThrow(); + expect(() => posterOptionsSchema.parse({ size: 'custom', widthMm: 700 })).toThrow(); + }); +}); diff --git a/apps/web/src/components/DashboardDialogs.test.tsx b/apps/web/src/components/DashboardDialogs.test.tsx index 0c16313..cf14c92 100644 --- a/apps/web/src/components/DashboardDialogs.test.tsx +++ b/apps/web/src/components/DashboardDialogs.test.tsx @@ -39,3 +39,15 @@ it('opens central account controls instead of collecting a password', async () = expect(screen.getByRole('link', { name: 'Manage account' })).toHaveAttribute('href', 'https://overroot.toni.foo/api/v1/auth/account'); expect(container.querySelector('input')).toBeNull(); }); + +it('passes poster dimensions and metadata when a poster template is selected', async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.change(screen.getByLabelText('Project name'), { target: { value: 'My poster' } }); + fireEvent.change(screen.getByLabelText('Authors'), { target: { value: 'Research Team' } }); + fireEvent.change(screen.getByLabelText('Print size'), { target: { value: 'custom' } }); + fireEvent.change(screen.getByLabelText('Width (mm)'), { target: { value: '1000' } }); + fireEvent.change(screen.getByLabelText('Height (mm)'), { target: { value: '700' } }); + await act(async () => { fireEvent.submit(screen.getByLabelText('Project name').closest('form')!); }); + expect(onCreate).toHaveBeenCalledWith('My poster', 'modern-ml', expect.objectContaining({ authors: 'Research Team', size: 'custom', widthMm: 1000, heightMm: 700 })); +}); diff --git a/apps/web/src/components/DashboardDialogs.tsx b/apps/web/src/components/DashboardDialogs.tsx index dbb3272..1f0620f 100644 --- a/apps/web/src/components/DashboardDialogs.tsx +++ b/apps/web/src/components/DashboardDialogs.tsx @@ -1,7 +1,7 @@ import { Check, KeyRound } from 'lucide-react'; import { useEffect, useRef, useState, type FormEvent } from 'react'; import type { ApiClient } from '../lib/api'; -import type { Template } from '../types'; +import type { PosterOptions, Template } from '../types'; import { InlineError } from './Feedback'; import { Modal } from './Modal'; import { PendingLabel } from './RootMark'; @@ -16,11 +16,12 @@ export function NewProjectDialog({ open: boolean; templates: Template[]; onClose: () => void; - onCreate: (name: string, template: string) => Promise; + onCreate: (name: string, template: string, poster?: PosterOptions) => Promise; onPendingChange?: (pending: boolean) => void; }) { const [name, setName] = useState('Untitled paper'); const [template, setTemplate] = useState('article'); + const [poster, setPoster] = useState({ subtitle: '', authors: '', affiliation: '', size: 'A1', orientation: 'landscape', columns: 2 }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const active = useRef(false); @@ -36,7 +37,8 @@ export function NewProjectDialog({ if (busy) return; setBusy(true); setError(null); onPendingChange?.(true); try { - await onCreate(name, template); + if (template === 'academic-classic' || template === 'modern-ml' || template === 'visual-results') await onCreate(name, template, poster); + else await onCreate(name, template); if (active.current && visible.current) onClose(); } catch (reason) { if (active.current) setError(reason instanceof Error ? reason.message : 'Could not create project'); @@ -54,6 +56,22 @@ export function NewProjectDialog({ {item.name}{item.description}{item.entryFile} )} + {(template === 'academic-classic' || template === 'modern-ml' || template === 'visual-results') &&
+ Poster setup + + + +
+ + + +
+ {poster.size === 'custom' &&
+ + +
} +

Edit poster sections and config.tex in Files after creation. Add vector PDF figures to figures/.

+
} {busy &&

You can close this dialog while the project is created.

}
diff --git a/apps/web/src/components/PdfViewer.tsx b/apps/web/src/components/PdfViewer.tsx index 4023308..ddaa674 100644 --- a/apps/web/src/components/PdfViewer.tsx +++ b/apps/web/src/components/PdfViewer.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist'; import type { ApiClient } from '../lib/api'; import { paintPdfHighlights } from '../lib/pdf-highlight'; +import { pageMillimetres, rasterScale } from '../lib/pdf-raster'; import { afterLayout, capturePdfViewState, loadStoredPdfViewState, restorePdfViewState, storePdfViewState, type PdfViewState @@ -22,6 +23,7 @@ export function PdfViewer({ focusMode, onFocusMode, projectName, + poster, onDocumentStatus }: { api: ApiClient; @@ -34,6 +36,7 @@ export function PdfViewer({ focusMode: boolean; onFocusMode: (focused: boolean) => void; projectName: string; + poster?: boolean; onDocumentStatus?: (data: ArrayBuffer, status: 'ready' | 'failed') => void; }) { const scroller = useRef(null); @@ -47,6 +50,7 @@ export function PdfViewer({ zoomRef.current = zoom; const [page, setPage] = useState(initialView.page); const [pageCount, setPageCount] = useState(0); + const [printSize, setPrintSize] = useState<{ width: number; height: number } | null>(null); const [rendering, setRendering] = useState(false); const [error, setError] = useState(null); const [sourceHighlights, setSourceHighlights] = useState([]); @@ -57,6 +61,7 @@ export function PdfViewer({ if (!data || !pages.current) { if (pages.current) pages.current.replaceChildren(); setPageCount(0); + setPrintSize(null); return; } let cancelled = false; @@ -68,13 +73,18 @@ export function PdfViewer({ .then(async (pdf) => { if (cancelled || !pages.current) return; setPageCount(pdf.numPages); + if (poster && pdf.numPages) { + const first = await pdf.getPage(1); + const physical = first.getViewport({ scale: 1 }); + if (!cancelled) setPrintSize(pageMillimetres(physical.width, physical.height)); + } const fragment = document.createDocumentFragment(); const renderJobs: Promise[] = []; for (let number = 1; number <= pdf.numPages; number += 1) { const pdfPage = await pdf.getPage(number); if (cancelled) return; const viewport = pdfPage.getViewport({ scale: zoom }); - const ratio = Math.min(window.devicePixelRatio || 1, 2); + const ratio = rasterScale(viewport.width, viewport.height, window.devicePixelRatio || 1); const wrapper = document.createElement('div'); wrapper.className = 'pdf-page'; wrapper.dataset.page = String(number); @@ -82,8 +92,8 @@ export function PdfViewer({ wrapper.style.height = `${viewport.height}px`; wrapper.setAttribute('aria-label', `PDF page ${number}`); const canvas = document.createElement('canvas'); - canvas.width = Math.floor(viewport.width * ratio); - canvas.height = Math.floor(viewport.height * ratio); + canvas.width = Math.max(1, Math.floor(viewport.width * ratio)); + canvas.height = Math.max(1, Math.floor(viewport.height * ratio)); canvas.style.width = `${viewport.width}px`; canvas.style.height = `${viewport.height}px`; wrapper.append(canvas); @@ -120,7 +130,7 @@ export function PdfViewer({ cancelled = true; void task.destroy(); }; - }, [data, revision, zoom]); + }, [data, revision, zoom, poster]); useEffect(() => { let cancelled = false; @@ -325,6 +335,9 @@ export function PdfViewer({ + {poster && printSize && + {pageCount === 1 ? '1 page' : `${pageCount} pages — check layout`} · {printSize.width} × {printSize.height} mm + } - {poster && printSize && - {pageCount === 1 ? '1 page' : `${pageCount} pages — check layout`} · {printSize.width} × {printSize.height} mm - }
+ {poster && printSize &&
+ {pageCount === 1 ? '1 page' : `${pageCount} pages: check layout`} · {printSize.width} × {printSize.height} mm +
} {!data && !loading &&
No PDF yet.Compile the project to see it here.
} {(loading || rendering) &&
Rendering PDF…
} {error &&
Preview failed.{error}
} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index e68bbee..9414519 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -342,7 +342,7 @@ button:disabled { cursor: default; opacity: .42; } .pdf-toolbar__group + .pdf-toolbar__group { margin-left: 5px; padding-left: 5px; border-left: 1px solid var(--line); } .pdf-toolbar__spacer { flex: 1; } .pdf-readout { min-width: 48px; text-align: center; color: var(--muted); font: 680 11px/1 var(--mono); } -.poster-print-status { color: var(--muted); font: 650 10px/1.3 var(--mono); white-space: nowrap; } +.poster-print-status { padding: 8px 12px; text-align: center; color: var(--muted); font: 650 11px/1.4 var(--mono); overflow-wrap: anywhere; } .pdf-scroll { position: relative; min-height: 0; overflow: auto; overscroll-behavior: contain; scrollbar-color: var(--line-strong) transparent; } .pdf-pages { min-width: max-content; display: grid; gap: 18px; justify-content: center; padding: 26px 32px 80px; } .pdf-page { position: relative; background: white; box-shadow: 0 2px 5px #1d302716, 0 12px 28px #1d302711; } diff --git a/docs/POSTERS.md b/docs/POSTERS.md index 7164b21..a76bc34 100644 --- a/docs/POSTERS.md +++ b/docs/POSTERS.md @@ -4,6 +4,8 @@ Create a project with **Academic Classic poster**, **Modern ML poster**, or **Vi The entry point is `poster.tex`. Edit text in `sections/*.tex`; put vector PDF figures in `figures/` and reference them with relative paths. `poster-layout.tex` contains shared layout macros. `config.tex` contains the print dimensions and metadata created by the setup form; edit it in **Files** to change settings later. All new poster text is placeholder guidance, not scientific evidence. Avoid treating example wording as a research claim. +For custom sizes, width and height are the final printed dimensions; orientation applies to A0/A1 presets. The generated source enables scalable Computer Modern fonts for poster-size headings. + Compile `poster.tex`. The PDF pane measures its first page and shows page count and width × height in millimetres. Check that there is exactly one page and that the dimensions match the print order. Download the PDF or export the full source ZIP from the workspace. The measurement does not certify font embedding, image resolution, trimming, or visual alignment, so inspect the exported PDF before printing. The viewer limits its raster bitmap size for large pages; this changes preview sharpness at high zoom, not the vector PDF download. -Poster source layouts were adapted from the MIT-licensed [Overposter](https://github.com/Slavov88/Overposter) project. Its license notice is retained in [OVERPOSTER-LICENSE.txt](OVERPOSTER-LICENSE.txt). The surrounding Overroot integration is contributed under this repository's AGPL-3.0-only license. +Poster source layouts were adapted from the MIT-licensed [Overposter](https://github.com/Slavov88/Overposter) project. Its license notice is retained in [OVERPOSTER-LICENSE.txt](OVERPOSTER-LICENSE.txt) and each generated project's `OVERPOSTER-LICENSE.txt`, including source ZIP exports. The surrounding Overroot integration is contributed under this repository's AGPL-3.0-only license.