diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 4944dc36ba8..9f5bba7c53e 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -45,6 +45,7 @@ All changes included in 1.11: ### `pdf` - ([#14982](https://github.com/quarto-dev/quarto-cli/issues/14982)): Fix a footnote in document metadata, such as `title` or `author`, making PDF and Typst output shift heading levels, which turned the first level-1 heading into the document title. +- ([#15027](https://github.com/quarto-dev/quarto-cli/issues/15027)): Fix PDF rendering with regional German variants such as `lang: de-CH` failing when German hyphenation patterns are not installed, instead of installing them automatically. ### `revealjs` diff --git a/src/command/render/latexmk/parse-error.ts b/src/command/render/latexmk/parse-error.ts index 2b760243231..a9c1588b0c0 100644 --- a/src/command/render/latexmk/parse-error.ts +++ b/src/command/render/latexmk/parse-error.ts @@ -156,36 +156,115 @@ export function findPdfAccessibilityWarnings( return result; } -// Finds missing hyphenation files (these appear as warnings in the log file) -export function findMissingHyphenationFiles(logText: string) { - //ngerman gets special cased - const filterLang = (lang: string) => { - // It seems some languages have no hyphenation files, so we just filter them out - // e.g. `lang: zh` has no hyphenation files - // https://github.com/quarto-dev/quarto-cli/issues/10291 - const noHyphen = ["chinese-hans", "chinese"]; - if (noHyphen.includes(lang)) { - return; - } +// Hyphenation pattern names (in language.dat) whose package is not +// hyphen-, according to `execute AddHyphen` in texlive.tlpdb +const kHyphenationPackageNames: Record = { + ngerman: "german", + swissgerman: "german", + pinyin: "chinese", + ukenglish: "english", + british: "english", + usenglishmax: "english", + bokmal: "norwegian", + norsk: "norwegian", + nynorsk: "norwegian", + schoolfinnish: "finnish", + monogreek: "greek", + ibycus: "ancientgreek", + classiclatin: "latin", + liturgicallatin: "latin", + mongolianlmc: "mongolian", + serbianc: "serbian", + // older names (synonyms in texlive.tlpdb) + amharic: "ethiopic", + geez: "ethiopic", + persian: "farsi", + patois: "french", + francais: "french", + polygreek: "greek", + portuges: "portuguese", + slovene: "slovenian", + espanol: "spanish", + // all of these are in the hyphen-indic package + assamese: "indic", + bengali: "indic", + gujarati: "indic", + hindi: "indic", + kannada: "indic", + malayalam: "indic", + marathi: "indic", + oriya: "indic", + pali: "indic", + panjabi: "indic", + tamil: "indic", + telugu: "indic", +}; + +// Maps a babel language name to the TeX Live package providing its +// hyphenation patterns (undefined when there is no such package) +function hyphenationPackageForLanguage(lang: string) { + // Names in language.dat may be in mixed case (e.g. UKenglish) + lang = lang.toLowerCase(); + + // It seems some languages have no hyphenation files, so we just filter them out + // e.g. `lang: zh` has no hyphenation files + // https://github.com/quarto-dev/quarto-cli/issues/10291 + const noHyphen = ["chinese-hans", "chinese"]; + if (noHyphen.includes(lang)) { + return; + } + + // NOTE Although the names of the corresponding lfd files match those in this list, + // there are some exceptions, particularly in German and Serbian. So, ngerman is + // called here german, which is the name in the CLDR and, actually, the most logical. + // + // See https://ctan.math.utah.edu/ctan/tex-archive/macros/latex/required/babel/base/babel.pdf + const name = Object.hasOwn(kHyphenationPackageNames, lang) + ? kHyphenationPackageNames[lang] + : lang; + const pkg = `hyphen-${name}`; + // The name comes from the log and is passed to tlmgr (through a .bat file + // on Windows), so only keep what looks like a package name (all + // hyphenation names in texlive.tlpdb are lowercase letters, digits and hyphens) + if (/^hyphen-[a-z0-9-]+$/.test(pkg)) { + return pkg; + } +} - // NOTE Although the names of the corresponding lfd files match those in this list, - // there are some exceptions, particularly in German and Serbian. So, ngerman is - // called here german, which is the name in the CLDR and, actually, the most logical. - // - // See https://ctan.math.utah.edu/ctan/tex-archive/macros/latex/required/babel/base/babel.pdf - if (lang === "ngerman") { - return "hyphen-german"; +// babel raises a fatal error (rather than the warning handled by +// findMissingHyphenationFiles) when a language is referenced but has neither +// hyphenation patterns in the format nor a dialect fallback. For instance, +// babel-german checks `\iflanguage{ngerman}` at begin document, which fails for +// `lang: de-CH` or `lang: de-AT` when hyphen-german is not installed: +// ! Package babel Error: You haven't defined the language 'ngerman' yet. +// https://github.com/rstudio/tinytex/issues/515 +// Older babel versions use `ngerman' or no quotes at all. +const kBabelUndefinedLanguageRegex = + /Package babel Error: You haven't defined the language [`']?([^`'\s]+)[`']? yet/g; + +// Finds hyphenation packages whose absence makes the compilation fail +// (to be installed directly by name, as opposed to the file search terms +// returned by findMissingFontsAndPackages) +export function findMissingHyphenationPackages(logText: string): string[] { + const packages: string[] = []; + for (const match of logText.matchAll(kBabelUndefinedLanguageRegex)) { + const pkg = hyphenationPackageForLanguage(match[1]); + if (pkg) { + packages.push(pkg); } - return `hyphen-${lang.toLowerCase()}`; - }; + } + return ld.uniq(packages); +} +// Finds missing hyphenation files (these appear as warnings in the log file) +export function findMissingHyphenationFiles(logText: string) { const babelWarningRegex = /^Package babel Warning:/m; const hasWarning = logText.match(babelWarningRegex); if (hasWarning) { const languageRegex = /^\(babel\).* language [`'](\S+)[`'].*$/m; const languageMatch = logText.match(languageRegex); if (languageMatch) { - return filterLang(languageMatch[1]); + return hyphenationPackageForLanguage(languageMatch[1]); } } @@ -196,7 +275,7 @@ export function findMissingHyphenationFiles(logText: string) { if (match) { const language = match[1]; if (language) { - return filterLang(language); + return hyphenationPackageForLanguage(language); } } } diff --git a/src/command/render/latexmk/pdf.ts b/src/command/render/latexmk/pdf.ts index 2e7d5010de2..756906d21e3 100644 --- a/src/command/render/latexmk/pdf.ts +++ b/src/command/render/latexmk/pdf.ts @@ -21,6 +21,7 @@ import { findLatexError, findMissingFontsAndPackages, findMissingHyphenationFiles, + findMissingHyphenationPackages, findPdfAccessibilityWarnings, kMissingFontLog, needsRecompilation, @@ -28,6 +29,7 @@ import { import { error, info, warning } from "../../../deno_ral/log.ts"; import { logProgress } from "../../../core/log.ts"; import { isWindows } from "../../../deno_ral/platform.ts"; +import * as ld from "../../../core/lodash.ts"; export async function generatePdf(mkOptions: LatexmkOptions): Promise { if (!mkOptions.quiet) { @@ -470,8 +472,15 @@ async function findAndInstallPackages( const logText = Deno.readTextFileSync(logFile); const searchTerms = findMissingFontsAndPackages(logText, dirname(logFile)); - if (searchTerms.length > 0) { - const packages = await pkgMgr.searchPackages(searchTerms); + // Packages known by name (no tlmgr file search needed) + const namedPackages = findMissingHyphenationPackages(logText); + if (searchTerms.length > 0 || namedPackages.length > 0) { + const packages = ld.uniq([ + ...namedPackages, + ...(searchTerms.length > 0 + ? await pkgMgr.searchPackages(searchTerms) + : []), + ]); if (packages.length > 0) { const packagesInstalled = await pkgMgr.installPackages( packages, diff --git a/tests/docs/smoke-all/2026/10/12/15027.qmd b/tests/docs/smoke-all/2026/10/12/15027.qmd new file mode 100644 index 00000000000..9ca84f6fa5e --- /dev/null +++ b/tests/docs/smoke-all/2026/10/12/15027.qmd @@ -0,0 +1,28 @@ +--- +title: "Swiss German hyphenation auto-install (#15027)" +format: pdf +lang: de-CH +_quarto: + tests: + pdf: null +--- + +**Only remove packages on CI** + +```{r} +#| eval: !expr isTRUE(as.logical(Sys.getenv("CI", "false"))) +#| include: false +#| messages: "NA" + +# Remove the German babel and hyphenation packages to test auto-installation. +# Without hyphen-german, babel fails with a fatal error for `lang: de-CH`: +# "You haven't defined the language 'ngerman' yet." +for (pkg in c("hyphen-german", "babel-german")) { + if (tinytex::check_installed(pkg)) { + message("Removing '", pkg, "' package for the render") + tinytex::tlmgr_remove(pkg) + } +} +``` + +Dieses Dokument prüft, dass Quarto `babel-german` und `hyphen-german` automatisch installiert, wenn `lang: de-CH` verwendet wird (siehe auch rstudio/tinytex#515). diff --git a/tests/unit/latexmk/parse-error.test.ts b/tests/unit/latexmk/parse-error.test.ts index 2410e00a66b..0c991a0b56c 100644 --- a/tests/unit/latexmk/parse-error.test.ts +++ b/tests/unit/latexmk/parse-error.test.ts @@ -5,9 +5,9 @@ * */ -import { findLatexError, findMissingFontsAndPackages, findMissingHyphenationFiles } from "../../../src/command/render/latexmk/parse-error.ts" +import { findLatexError, findMissingFontsAndPackages, findMissingHyphenationFiles, findMissingHyphenationPackages } from "../../../src/command/render/latexmk/parse-error.ts" import { unitTest } from "../../test.ts"; -import { assert } from "testing/asserts"; +import { assert, assertEquals } from "testing/asserts"; function fontSearchTerm(font: string): string { const fontPattern = font.replace(/\s+/g, '\\s*'); @@ -163,4 +163,176 @@ unitTest("Detect missing hyphenation with babel warnings", async () => { ); }, { cwd: () => "unit/latexmk/" -}) \ No newline at end of file +}) + +unitTest("Detect missing hyphenation causing a fatal babel error", async () => { + // `lang: de-CH` without hyphen-german (https://github.com/rstudio/tinytex/issues/515): + // babel-german defines nswissgerman as a dialect of the null language, but then + // checks `\iflanguage{ngerman}`, which is a fatal error, so compilation fails. + const logSwissGerman = `Package babel Warning: No hyphenation patterns were preloaded for +(babel) the language 'German (current orthography), needed by Swiss Standard German (current orthography)' into the format. +(babel) Please, configure your TeX system to add them and +(babel) rebuild the format. Now I will use the patterns +(babel) preloaded for \\language=0 instead on input line 1260. + +Package babel Info: Hyphen rules for 'nswissgerman' set to \\l@nil +(babel) (\\language5). Reported on input line 1260. + +! Package babel Error: You haven't defined the language 'ngerman' yet. +(babel) Perhaps you misspelled it or your installation +(babel) is not complete. + +See the babel package documentation for explanation. +Type H for immediate help.`; + assertEquals( + findMissingHyphenationPackages(logSwissGerman), + ["hyphen-german"], + "Should map the undefined ngerman language to hyphen-german", + ); + // The file-based search finds nothing for this error + assertEquals(findMissingFontsAndPackages(logSwissGerman, "."), []); + + // Backtick-quote format (older babel) + assertEquals( + findMissingHyphenationPackages( + "! Package babel Error: You haven't defined the language `ngerman' yet.", + ), + ["hyphen-german"], + ); + // Unquoted format (older babel) + assertEquals( + findMissingHyphenationPackages( + "! Package babel Error: You haven't defined the language ngerman yet.", + ), + ["hyphen-german"], + ); + // Other languages are lowercased and prefixed + assertEquals( + findMissingHyphenationPackages( + "! Package babel Error: You haven't defined the language 'Spanish' yet.", + ), + ["hyphen-spanish"], + ); + // Languages without hyphenation package are ignored + assertEquals( + findMissingHyphenationPackages( + "! Package babel Error: You haven't defined the language 'chinese-hans' yet.", + ), + [], + ); + // Only the fatal error is considered, not babel warnings + assertEquals( + findMissingHyphenationPackages(`Package babel Warning: No hyphenation patterns were preloaded for +(babel) the language 'Spanish' into the format.`), + [], + ); + assertEquals(findMissingHyphenationPackages("asdf qwer"), []); +}, { + cwd: () => "unit/latexmk/" +}) + +unitTest("Drop hyphenation package names that are not valid package names", async () => { + // Language names come from the log and end up as tlmgr arguments, which + // go through a .bat file on Windows + for (const lang of ['x"&calc', "ngerman;id", "%PATH%", "a|b", "a^b", "--repository=x"]) { + assertEquals( + findMissingHyphenationPackages( + `! Package babel Error: You haven't defined the language '${lang}' yet.`, + ), + [], + `Should drop '${lang}' from the babel error`, + ); + assertEquals( + findMissingHyphenationFiles(`Package babel Warning: No hyphenation patterns were preloaded for +(babel) the language '${lang}' into the format.`), + undefined, + `Should drop '${lang}' from the babel warning`, + ); + } + // Valid names are kept + assertEquals( + findMissingHyphenationPackages( + `! Package babel Error: You haven't defined the language 'x"&calc' yet. +! Package babel Error: You haven't defined the language 'french' yet.`, + ), + ["hyphen-french"], + ); +}, { + cwd: () => "unit/latexmk/" +}) + +unitTest("Map hyphenation language names to their TeX Live package", async () => { + // Names whose package is not hyphen-, according to + // `execute AddHyphen` (name= and synonyms=) in texlive.tlpdb + const expected: Record = { + ngerman: "hyphen-german", + swissgerman: "hyphen-german", + pinyin: "hyphen-chinese", + ukenglish: "hyphen-english", + british: "hyphen-english", + usenglishmax: "hyphen-english", + bokmal: "hyphen-norwegian", + norsk: "hyphen-norwegian", + nynorsk: "hyphen-norwegian", + schoolfinnish: "hyphen-finnish", + monogreek: "hyphen-greek", + polygreek: "hyphen-greek", + ibycus: "hyphen-ancientgreek", + classiclatin: "hyphen-latin", + liturgicallatin: "hyphen-latin", + mongolianlmc: "hyphen-mongolian", + serbianc: "hyphen-serbian", + amharic: "hyphen-ethiopic", + geez: "hyphen-ethiopic", + persian: "hyphen-farsi", + patois: "hyphen-french", + francais: "hyphen-french", + portuges: "hyphen-portuguese", + slovene: "hyphen-slovenian", + espanol: "hyphen-spanish", + assamese: "hyphen-indic", + bengali: "hyphen-indic", + gujarati: "hyphen-indic", + hindi: "hyphen-indic", + kannada: "hyphen-indic", + malayalam: "hyphen-indic", + marathi: "hyphen-indic", + oriya: "hyphen-indic", + pali: "hyphen-indic", + panjabi: "hyphen-indic", + tamil: "hyphen-indic", + telugu: "hyphen-indic", + }; + for (const [lang, pkg] of Object.entries(expected)) { + assertEquals( + findMissingHyphenationPackages( + `! Package babel Error: You haven't defined the language '${lang}' yet.`, + ), + [pkg], + `Should map '${lang}' to ${pkg} from the babel error`, + ); + assertEquals( + findMissingHyphenationFiles(`Package babel Warning: No hyphenation patterns were preloaded for +(babel) the language '${lang}' into the format.`), + pkg, + `Should map '${lang}' to ${pkg} from the babel warning`, + ); + } + // Names in language.dat may be in mixed case + assertEquals( + findMissingHyphenationPackages( + "! Package babel Error: You haven't defined the language 'UKenglish' yet.", + ), + ["hyphen-english"], + ); + // Several languages from the same package are installed once + assertEquals( + findMissingHyphenationPackages( + `! Package babel Error: You haven't defined the language 'hindi' yet. +! Package babel Error: You haven't defined the language 'tamil' yet.`, + ), + ["hyphen-indic"], + ); +}, { + cwd: () => "unit/latexmk/" +})