diff --git a/news/changelog-1.7.md b/news/changelog-1.7.md index 62ff1c51b1..4c02248f42 100644 --- a/news/changelog-1.7.md +++ b/news/changelog-1.7.md @@ -28,6 +28,11 @@ All changes included in 1.7: ## `typst` Format - ([#11578](https://github.com/quarto-dev/quarto-cli/issues/11578)): Typst column layout widths use fractional `fr` units instead of percent `%` units for unitless and default widths in order to fill the enclosing block and not spill outside it. +- ([#11835](https://github.com/quarto-dev/quarto-cli/issues/11835)): Take markdown structure into account when detecting minimum heading level. + +## `pdf` Format + +- ([#11835](https://github.com/quarto-dev/quarto-cli/issues/11835)): Take markdown structure into account when detecting minimum heading level. ## Lua Filters and extensions diff --git a/src/core/lib/break-quarto-md.ts b/src/core/lib/break-quarto-md.ts index 0f517ad675..dfe7ba3a02 100644 --- a/src/core/lib/break-quarto-md.ts +++ b/src/core/lib/break-quarto-md.ts @@ -7,7 +7,7 @@ * Copyright (C) 2021-2022 Posit Software, PBC */ -import { lineOffsets, lines } from "./text.ts"; +import { lineOffsets } from "./text.ts"; import { Range, rangedLines, RangedSubstring } from "./ranged-text.ts"; import { asMappedString, diff --git a/src/core/lib/markdown-analysis/level-one-headings.ts b/src/core/lib/markdown-analysis/level-one-headings.ts new file mode 100644 index 0000000000..5722cca404 --- /dev/null +++ b/src/core/lib/markdown-analysis/level-one-headings.ts @@ -0,0 +1,24 @@ +/* + * level-one-headings.ts + * + * Copyright (C) 2025 Posit Software, PBC + */ + +import { join } from "../../../deno_ral/path.ts"; +import { execProcess } from "../../process.ts"; +import { pandocBinaryPath, resourcePath } from "../../resources.ts"; + +export async function hasLevelOneHeadings(markdown: string): Promise { + // this is O(n * m) where n is the number of blocks and m is the number of matches + // we could do better but won't until we profile and show it's a problem + + const path = pandocBinaryPath(); + const filterPath = resourcePath( + join("filters", "quarto-internals", "leveloneanalysis.lua"), + ); + const result = await execProcess({ + cmd: [path, "-f", "markdown", "-t", "markdown", "-L", filterPath], + stdout: "piped", + }, markdown); + return result.stdout?.trim() === "true"; +} diff --git a/src/format/pdf/format-pdf.ts b/src/format/pdf/format-pdf.ts index eea2cbe430..cf5b1b4109 100644 --- a/src/format/pdf/format-pdf.ts +++ b/src/format/pdf/format-pdf.ts @@ -53,6 +53,7 @@ import { kTemplatePartials } from "../../command/render/template.ts"; import { copyTo } from "../../core/copy.ts"; import { kCodeAnnotations } from "../html/format-html-shared.ts"; import { safeModeFromFile } from "../../deno_ral/fs.ts"; +import { hasLevelOneHeadings as hasL1Headings } from "../../core/lib/markdown-analysis/level-one-headings.ts"; export function pdfFormat(): Format { return mergeConfigs( @@ -138,7 +139,7 @@ function createPdfFormat( metadata: { ["block-headings"]: true, }, - formatExtras: ( + formatExtras: async ( _input: string, markdown: string, flags: PandocFlags, @@ -254,7 +255,7 @@ function createPdfFormat( }; // Don't shift the headings if we see any H1s (we can't shift up any longer) - const hasLevelOneHeadings = !!markdown.match(/\n^#\s.*$/gm); + const hasLevelOneHeadings = await hasL1Headings(markdown); // pdfs with no other heading level oriented options get their heading level shifted by -1 if ( diff --git a/src/format/typst/format-typst.ts b/src/format/typst/format-typst.ts index eb2f5f4484..f36460b64b 100644 --- a/src/format/typst/format-typst.ts +++ b/src/format/typst/format-typst.ts @@ -29,6 +29,7 @@ import { } from "../../config/types.ts"; import { formatResourcePath } from "../../core/resources.ts"; import { createFormat } from "../formats-shared.ts"; +import { hasLevelOneHeadings as hasL1Headings } from "../../core/lib/markdown-analysis/level-one-headings.ts"; export function typstFormat(): Format { return createFormat("Typst", "pdf", { @@ -44,14 +45,14 @@ export function typstFormat(): Format { [kCiteproc]: false, }, resolveFormat: typstResolveFormat, - formatExtras: ( + formatExtras: async ( _input: string, markdown: string, flags: PandocFlags, format: Format, _libDir: string, _services: RenderServices, - ): FormatExtras => { + ): Promise => { const pandoc: FormatPandoc = {}; const metadata: Metadata = {}; @@ -68,7 +69,7 @@ export function typstFormat(): Format { // unless otherwise specified, pdfs with only level 2 or greater headings get their // heading level shifted by -1. - const hasLevelOneHeadings = !!markdown.match(/\n^#\s.*$/gm); + const hasLevelOneHeadings = await hasL1Headings(markdown); if ( !hasLevelOneHeadings && flags?.[kShiftHeadingLevelBy] === undefined && diff --git a/src/resources/filters/quarto-internals/leveloneanalysis.lua b/src/resources/filters/quarto-internals/leveloneanalysis.lua new file mode 100644 index 0000000000..155859fc1d --- /dev/null +++ b/src/resources/filters/quarto-internals/leveloneanalysis.lua @@ -0,0 +1,18 @@ +local found = false +function Header(el) + found = found or el.level == 1 + return nil +end + +function Pandoc(doc) + if found then + doc.blocks = pandoc.Blocks({ + pandoc.Str("true") + }) + else + doc.blocks = pandoc.Blocks({ + pandoc.Str("false") + }) + end + return doc +end \ No newline at end of file diff --git a/tests/docs/smoke-all/2025/01/10/issue-11835-b.qmd b/tests/docs/smoke-all/2025/01/10/issue-11835-b.qmd new file mode 100644 index 0000000000..0dad5f618d --- /dev/null +++ b/tests/docs/smoke-all/2025/01/10/issue-11835-b.qmd @@ -0,0 +1,17 @@ +--- +format: latex +number-sections: true +_quarto: + tests: + latex: + ensureFileRegexMatches: + - [] + - ["subsection{Section}"] +--- + +## Section + +```r +# this is a comment that shouldn't break quarto +print("Hello, world") +``` \ No newline at end of file diff --git a/tests/docs/smoke-all/2025/01/10/issue-11835.qmd b/tests/docs/smoke-all/2025/01/10/issue-11835.qmd new file mode 100644 index 0000000000..8e38c1afd8 --- /dev/null +++ b/tests/docs/smoke-all/2025/01/10/issue-11835.qmd @@ -0,0 +1,17 @@ +--- +format: typst +number-sections: true +_quarto: + tests: + typst: + ensurePdfRegexMatches: + - ["1 Section"] + - ["0.1 Section"] +--- + +## Section + +```r +# this is a comment that shouldn't break quarto +print("Hello, world") +``` \ No newline at end of file