Skip to content

feat: PDF export for recipes - #3508

Open
DerWossi wants to merge 8 commits into
nextcloud:masterfrom
DerWossi:master
Open

DerWossi wants to merge 8 commits into
nextcloud:masterfrom
DerWossi:master

Conversation

@DerWossi

@DerWossi DerWossi commented Oct 26, 2026 •

Copy link
Copy Markdown

Topic and Scope

Clear PDF export for recipes. Usefull for sharing with friends without Nextcloud and loving printed paper ;-)
Print2PDF doesn't meet this requirement because it's too detailed. Also, it's not intuitive to find if you're a "simple" user.

Concerns/issues

Unicode restriction in the PDF.
This could apply to Arabic or Japanese recipe texts and emojis, for example. A Unicode font would be required for full Unicode support.

Formal requirements

There are some formal requirements that should be satisfied. Please mark those by checking the corresponding box.

  • I did check that the app can still be opened and does not throw any browser logs
  • I created tests for newly added PHP code (check this if no PHP changes were made)
  • I updated the OpenAPI specs and added an entry to the API changelog (check if API was not modified)
  • I notified the matrix channel if I introduced an API change

I uses Copilot for coding and testing

@coderabbitai

coderabbitai Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b6e51de3-292f-4d45-b96c-958bff8df550
Commits

Reviewing files that changed from the base of the PR and between d29d8d4 and cc9c382.

Files selected for processing: 3
  • src/components/RecipeView/RecipeTimer.vue
  • src/js/recipePdfExport.ts
  • src/tests/unit/recipePdfExport.test.js
Files skipped from review as they are similar to previous changes: 1
  • src/js/recipePdfExport.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 2 remain after this review.

Walkthrough

The change adds jsPDF and implements a recipe PDF exporter. The exporter collects recipe details and sections, formats them into a paginated A4 document, and saves the PDF with a sanitized recipe filename. A recipe-view menu action emits an export event. RecipeView handles the event, exports the loaded recipe using the current yield, and displays a success or failure notification. The timer exposes original time through a data attribute. Unit tests cover text sanitization, filename generation, and exported content.

Priority: Normal

Merge Risk: Moderate · up to cc9c3

Recipe exports can contain incomplete or misleading content and poorly formatted text; these issues should be corrected before merge.

Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title clearly and concisely identifies the main change: adding PDF export for recipes.
Description check Passed The description includes all required sections, explains the scope, identifies Unicode limitations, and addresses the formal requirements. The unchecked Matrix notification is consistent with the abse…
Docstring Coverage Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Documentation-only edits and deletions within survi…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description includes all required sections, explains the scope, identifies Unicode limitations, and addresses the formal requirements. The unchecked Matrix notification is consistent with the absence of API changes.

Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 3 functions across 2 files. (1 skipped: 1 unsupported.)

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Some tools did not complete. Review the errors below.

ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/components/RecipeView/RecipeTimer.vue

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

src/js/recipePdfExport.ts

ESLint skipped: the matched ESLint configuration already failed (missing-dependency).

src/tests/unit/recipePdfExport.test.js

ESLint skipped: the matched ESLint configuration already failed (missing-dependency).

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


Review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 4a27f9b2-33f4-4906-abfc-96a59d019522
Commits

Reviewing files that changed from the base of the PR and between 22e089f and 867547c.

Files ignored due to path filters: 1
  • package-lock.json is excluded by !**/package-lock.json
Files selected for processing: 7
  • l10n/de.js
  • l10n/de.json
  • l10n/de_DE.js
  • l10n/de_DE.json
  • package.json
  • src/components/AppControls/AppControls.vue
  • src/components/RecipeView/RecipeView.vue

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.

Comment on lines +772 to +775
const renderedText = (selector: string) =>
Array.from(document.querySelectorAll<HTMLElement>(selector))
.map((element) => element.innerText.trim())
.filter(Boolean);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

🔎 Supported by static analysis

Script executed:

rg -n 'normalizeMarkdown|Loading|exportRecipeAsPdf|renderedText' src/components/RecipeView/RecipeView.vue
sed -n '720,800p' src/components/RecipeView/RecipeView.vue

Repository: nextcloud/cookbook

Length of output: 4395


Script executed:

set -o pipefail
printf '%s\n' '--- template and export trigger references ---'
rg -n -F -e 'exportRecipePdf' -e 'parsedDescription' -e 'parsedIngredients' -e 'parsedInstructions' -e 'parsedTools' -e 'isLoading' src/components/RecipeView/RecipeView.vue
printf '%s\n' '--- template start ---'
sed -n '1,180p' src/components/RecipeView/RecipeView.vue
printf '%s\n' '--- watcher and export lifecycle ---'
sed -n '900,995p' src/components/RecipeView/RecipeView.vue
sed -n '1010,1080p' src/components/RecipeView/RecipeView.vue

Repository: nextcloud/cookbook

Length of output: 15298


Script executed:

set -o pipefail
printf '%s\n' '--- setup and loading transitions ---'
sed -n '560,675p' src/components/RecipeView/RecipeView.vue
printf '%s\n' '--- export event emitters and controls ---'
rg -n -F --glob '*.vue' --glob '*.ts' --glob '*.js' -e 'exportRecipePdf' -e 'Export as PDF' -e 'PDF' src
printf '%s\n' '--- nearby control component usage ---'
sed -n '180,380p' src/components/RecipeView/RecipeView.vue

Repository: nextcloud/cookbook

Length of output: 14477


Script executed:

set -o pipefail
printf '%s\n' '--- AppControls export control ---'
sed -n '130,190p' src/components/AppControls/AppControls.vue
sed -n '300,340p' src/components/AppControls/AppControls.vue
printf '%s\n' '--- AppControls setup and parent usage ---'
sed -n '1,130p' src/components/AppControls/AppControls.vue
rg -n -F --glob '*.vue' --glob '*.ts' -e '<AppControls' -e 'loadingRecipe' -e 'setLoadingRecipe' -e 'setRecipe' src

Repository: nextcloud/cookbook

Length of output: 12019


Script executed:

set -o pipefail
printf '%s\n' '--- normalizeMarkdown binding ---'
rg -n -F --glob '*.ts' --glob '*.js' --glob '*.vue' -e 'function normalizeMarkdown' -e 'const normalizeMarkdown' -e 'export default normalizeMarkdown' -e 'title-rename' .
printf '%s\n' '--- export content selectors ---'
sed -n '835,915p' src/components/RecipeView/RecipeView.vue
printf '%s\n' '--- rendered child components ---'
rg -n -F -e 'class="ingredient"' -e 'class="instruction"' -e 'class="markdown-tool"' src/components

Repository: nextcloud/cookbook

Length of output: 3401


Script executed:

set -o pipefail
printf '%s\n' '--- candidate title-rename files ---'
rg --files -g '*title*rename*' -g '*rename*' src apps . 2>/dev/null | head -80
printf '%s\n' '--- alias configuration and module references ---'
rg -n -F --glob '*.{js,ts,json,vue}' -e 'title-rename' -e 'cookbook/js' webpack.config.js vite.config.* package.json tsconfig.json src 2>/dev/null || true

Repository: nextcloud/cookbook

Length of output: 3344


Script executed:

set -o pipefail
sed -n '1,220p' src/js/title-rename.js

Repository: nextcloud/cookbook

Length of output: 2514


Wait for recipe text before exporting it.

For recipe text containing #r/<id> links, normalizeMarkdown waits for recipe API requests. The export action is already enabled when the recipe view is active. If the user exports before those requests finish, exportRecipeAsPdf reads Loading… from the rendered elements and saves the placeholder text while reporting success. Keep the export action unavailable until normalization finishes, or export the resolved recipe values directly.

if (nutrition.length) {
addSection(t('cookbook', 'Nutrition Information'));
for (const item of nutrition) {
addText(item.replace(/\s+/g, ': '));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

🔎 Supported by static analysis

Script executed:

sed -n '850,875p' src/components/RecipeView/RecipeView.vue
cat src/components/RecipeView/RecipeNutritionInfoItem.vue

Repository: nextcloud/cookbook

Length of output: 1762


Script executed:

nl -ba src/components/RecipeView/RecipeView.vue | sed -n '180,280p;850,875p'
printf '\n--- RecipeNutritionInfoItem.vue ---\n'
nl -ba src/components/RecipeView/RecipeNutritionInfoItem.vue
printf '\n--- nutrition component usages ---\n'
rg -n -F -- 'RecipeNutritionInfoItem' src/components/RecipeView

Repository: nextcloud/cookbook

Length of output: 8632


Preserve spaces inside nutrition values.

Each .nutrition-items > li contains a title <div> and a data <div>. For Energy and 100 kcal, innerText yields Energy\n100 kcal. Replacing every whitespace run produces Energy: 100: kcal. Replace only the title/data line break.

Suggested fix
--- "a/src/components/RecipeView/RecipeView.vue"
+++ "b/src/components/RecipeView/RecipeView.vue"
@@ -863,7 +863,7 @@
         if (nutrition.length) {
             addSection(t('cookbook', 'Nutrition Information'));
             for (const item of nutrition) {
-                addText(item.replace(/\s+/g, ': '));
+                addText(item.replace(/\s*\n\s*/g, ': '));
             }
         }
 
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
addText(item.replace(/\s+/g, ': '));
addText(item.replace(/\s*\n\s*/g, ': '));

Comment on lines +875 to +882
.filter((element) => {
const child = element.firstElementChild;
return !(
element.children.length === 1 &&
child &&
/^H[1-6]$/.test(child.tagName)
);
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Functional Correctness · Quick win

Keep instruction headings in the PDF.

When an instruction is a standalone Markdown heading, this filter removes its text. The PDF then loses section names such as “Dough” and “Filling,” which can make the remaining steps ambiguous. Render headings as unnumbered section labels instead of discarding them. The instruction component renders each instruction through VueShowdown. (raw.githubusercontent.com)

@DerWossi DerWossi changed the title PDF export for recipes feat: PDF export for recipes Oct 26, 2026
@christianlupus

Copy link
Copy Markdown
Collaborator

Hello @DerWossi, first, thanks for taking the time. I am just on mobile, so I cannot effectively check your main contribution (the big function block). However I have a few comments, you might want to address them in a quick manner. Otherwise, we can address this in a dedicated step:

  • Do not alter the translations. They are auto-translated and will be overwritten nightly. Don't bother with them.
  • Your main printing function is a big blob of code. Can we put the code into a dedicated file and import that from the compiler? That should should avoid making the file even more complex...

Anything else after real review

@DerWossi

Copy link
Copy Markdown
Author

Thanks @christianlupus for your fast and appreciated answer!

  • Do not alter the translations. They are auto-translated and will be overwritten nightly. Don't bother with them.
    Got it
  • Your main printing function is a big blob of code. Can we put the code into a dedicated file and import that from the compiler? That should should avoid making the file even more complex...

I will move the new function in a seperate module to keep the original code clean. I should have thought of it right away...

Maybe i need to open a new PR then? I don't have much experience with it yet.
All my other projects are "privat"...

br
Peter

Revert f20161e and 4ad0676.

Assisted-by: GitHub Copilot:claude

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: DerWossi <dev@team-moellers.de>
@christianlupus

Copy link
Copy Markdown
Collaborator

No problem, just keep commiting to this PR's branch. It will be appended and squashed in the end. Don't worry too much about it (unless you accidentally committed private data).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


Review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: aff5a790-f468-4242-a5c4-7441db46d3ac
Commits

Reviewing files that changed from the base of the PR and between 867547c and d29d8d4.

Files selected for processing: 3
  • src/components/RecipeView/RecipeView.vue
  • src/js/recipePdfExport.ts
  • src/tests/unit/recipePdfExport.test.js

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.

Comment thread src/js/recipePdfExport.ts Resolved
Comment thread src/js/recipePdfExport.ts
Comment on lines +127 to +129
ensureSpace(LINE_HEIGHT);
pdf.text(line, MARGIN, cursor);
cursor += LINE_HEIGHT;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

Increase the line spacing for wrapped titles.

When a long recipe name wraps, addText places its 20-point lines only 5 mm apart. The lines can overlap in the saved PDF. Calculate the baseline step from fontSize, and use that step for both ensureSpace and cursor. jsPDF measures font size in points, while this document uses millimetres for positions. (parallax.github.io)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants