From 973d2c18355c261e33c0a605d0453a15d6ea5510 Mon Sep 17 00:00:00 2001 From: Stephen Beitzel Date: Sat, 3 Oct 2026 09:41:26 -0700 Subject: [PATCH] =?UTF-8?q?Fix=20#198=20=E2=80=94=20%%stretchlast=20and=20?= =?UTF-8?q?%%stretchstaff,=20as=20abcm2ps=20implements=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last line of a tune was drawn at natural spacing, which packs eighths almost head to head next to the justified lines above it. abcm2ps 8.14.0 does two things instead, and CeolKit now does both: - %%stretchlast F (0…1, default 0.25): the last system is stretched to the line when its unstretched staff reaches 1 − F of it. The spec's logical form is accepted as 1/0. - A last system that is not stretched is spaced like the line before it, never below natural and never past the line. %%stretchstaff false stretches no system at all; ckprobe --natural now forces it, so it reports natural widths for every system. %%ceolkit:justifylast is deprecated: true/false parse as %%stretchlast 1/0 with a deprecatedDirective warning. CeolKitDirective.justifyLast(Bool) is replaced by .stretchLast(Double) and .stretchStaff(Bool), and SVGRenderConfig.justifyLastSystem by stretchLast/stretchStaff — release notes need an Upgrading entry, since the last line of most tunes changes. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +- EXTENSIONS.md | 58 +- README.md | 2 +- Sources/CeolKitModel/CeolKitDirective.swift | 11 +- Sources/CeolKitModel/Diagnostic.swift | 6 +- .../CeolKitParser/Semantic/SemanticPass.swift | 40 +- .../CeolKitSVGRenderer.swift | 2 +- .../Config/SVGRenderConfig.swift | 16 +- .../CeolKitSVGRenderer/Layout/Justifier.swift | 103 +++- .../Layout/LayoutDirectives.swift | 14 +- Sources/ckprobe/Options.swift | 4 +- Sources/ckprobe/SourceRewriter.swift | 2 +- .../Extensions/CeolKitExtensionTests.swift | 97 ++-- .../DirectiveScopeTests.swift | 22 +- .../EventVoiceTagTests.swift | 4 +- .../JustifierTests.swift | 92 ++- .../LineBreakBalanceTests.swift | 4 +- .../PageOrientationTests.swift | 4 +- .../SharedStaffMergeTests.swift | 2 +- .../StaffBraceTests.swift | 2 +- .../StaffBracketTests.swift | 4 +- .../StaffPlanRegionTests.swift | 2 +- .../StaffSpanThreadingTests.swift | 2 +- .../CeolKitSVGRendererTests/StyleTests.swift | 59 +- .../TunebookTests.swift | 2 +- .../VoiceLabelTests.swift | 2 +- .../pageMatchesSnapshot.1.txt | 544 +++++++++--------- .../zochartiPageMatchesSnapshot.1.txt | 222 +++---- Tests/CeolKitSVGRendererTests/tunebook.abc | 2 +- 29 files changed, 781 insertions(+), 548 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 77c565f..87b39ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ It is the fastest way to answer a layout question without a GUI: swift run ckprobe tune.abc # full report swift run ckprobe tune.abc --scale 0.6 # override %%scale (default 0.75) swift run ckprobe tune.abc --sweep 1.0,0.75,0.6 # systems/pages per %%scale -swift run ckprobe tune.abc --natural # unstretched system widths +swift run ckprobe tune.abc --natural # unstretched system widths (%%stretchstaff 0) swift run ckprobe tune.abc --out /tmp/out --json swift run ckprobe --fonts [--system-fonts] # faces text could be set in swift run ckprobe tune.abc --out /tmp/out --font-face # + @font-face @@ -112,7 +112,8 @@ Eight `%%ceolkit:*` directives are first-class model members: - `%%ceolkit:pipeformat true|false` - `%%ceolkit:pagenumber N` - `%%ceolkit:stemalignment N` -- `%%ceolkit:justifylast true|false` +- `%%ceolkit:justifylast true|false` (**deprecated**: parsed as `%%stretchlast 1|0` with a + `deprecatedDirective` warning; the last line is abcm2ps's `%%stretchlast`/`%%stretchstaff`) - `%%ceolkit:scale F` (**deprecated**: parsed as `%%pagescale F` with a `deprecatedDirective` warning; the page scale is `%%scale`/`%%pagescale`, as in abcm2ps — see `EXTENSIONS.md`) - `%%ceolkit:gracenotespacing F` (F >= 1, in grace notehead widths; tune-wide, never per-voice) diff --git a/EXTENSIONS.md b/EXTENSIONS.md index 505a47f..bd02e47 100644 --- a/EXTENSIONS.md +++ b/EXTENSIONS.md @@ -161,21 +161,18 @@ K:A --- -## `%%ceolkit:justifylast` +## `%%ceolkit:justifylast` (deprecated) **Syntax:** `%%ceolkit:justifylast ` -**Type:** boolean (`true` or `false`; case-insensitive) - -**Default:** `false` - -**Scope:** global (file preamble or tune header) - -### Description +**Status:** deprecated since issue #198; use `%%stretchlast` (see +[The last line](#the-last-line-stretchlast-and-stretchstaff) below). It will be removed +in a later release. -The default way to render a tune is not to justify the last line, leaving it ragged. -This setting, when `true`, forces the last line to be justified so that it spans the -whole width. +`%%ceolkit:justifylast true` now means `%%stretchlast 1`, and `false` means +`%%stretchlast 0`. Either produces a `deprecatedDirective` warning naming the +replacement. Note that `false` no longer draws the last line at its natural width: +like abcm2ps, a short last line takes the spacing of the line above it. --- @@ -565,6 +562,45 @@ be removed in a later release. the title block and the `W:` words along with the music, which it did not do before. +## The last line: `%%stretchlast` and `%%stretchstaff` + +These are abcm2ps's directives (ABC v2.2 §11.4.3 lists both), implemented the way +abcm2ps 8.14.0 behaves (issue #198). Every system but the last is stretched to the +full line. The last system of a tune is handled like this: + +- **`%%stretchlast F`**, with `F` from 0 to 1. The default is **0.25**. The last + system is stretched to the full line when its unstretched staff (header and music) + reaches `1 − F` of the line. At the default, that means three quarters of the line. + `1` stretches every last line, and `0` stretches none. The spec's logical form is + accepted too, with `true` meaning 1 and `false` meaning 0. abcm2ps accepts only the + number. +- A last system that is **not** stretched is spaced like the line before it, so its + notes do not suddenly close up at the end of the tune. It is never squeezed below + its natural width and never runs past the line. A tune of one line has no line + before it, so its last line is drawn at natural width. +- **`%%stretchstaff `** (default `true`). `false` stretches no system at + all, the last one included. A system that overruns the line is still compressed to + fit. + +A value that is not a number from 0 to 1, or a logical, produces an +`invalidStretchLast` warning, and the directive is ignored. + +Both are scoped like `%%scale`: written in the file header, the value governs every +tune; written in a tune, it governs that tune only. `SVGRenderConfig.stretchLast` and +`SVGRenderConfig.stretchStaff` set the values the host uses where the document says +nothing. + +```abc +X:1 +T:Last line always stretched +%%stretchlast 1 +M:6/8 +L:1/8 +K:G +GAB cde | dcB A3 | B2c d2d | cdc B3 | +B2c d2d |] +``` + ## The page scale: `%%scale` and `%%pagescale` These are abcm2ps's directives (ABC v2.2 §11.4.3 lists `%%scale`), implemented diff --git a/README.md b/README.md index 3fecd24..1f4f11f 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ pages: 2 | `--scale ` | Override `%%scale` (abcm2ps's `-s`; default 0.75) before rendering. | | `--grace-spacing ` | Override `%%ceolkit:gracenotespacing` (≥ 1) before rendering. | | `--sweep ` | Render at each `%%scale`; print a systems/pages table. | -| `--natural` | Force `%%ceolkit:justifylast false`, so widths are unstretched. | +| `--natural` | Force `%%stretchstaff 0`, so every system's width is unstretched. | | `--out ` | Write `page0.svg`, `page1.svg`, … into ``. | | `--json` | Emit JSON instead of the text report. | diff --git a/Sources/CeolKitModel/CeolKitDirective.swift b/Sources/CeolKitModel/CeolKitDirective.swift index de015a4..26e7437 100644 --- a/Sources/CeolKitModel/CeolKitDirective.swift +++ b/Sources/CeolKitModel/CeolKitDirective.swift @@ -18,7 +18,16 @@ public enum CeolKitDirective: Hashable, Sendable { case graceNoteSpacing(Double) // %%ceolkit:gracenotespacing F (F >= 1) case landscape(Bool) // %%landscape 0|1 (ABC v2.2 §9.1) case flatBeams(Bool) // %%flatbeams true|false (abcm2ps; implicit in pipeFormat) - case justifyLast(Bool) // %%ceolkit:justifylast true|false + /// How short the last system of a tune may be and still be stretched to the full line, + /// as abcm2ps's `%%stretchlast` value (issue #198): the system is stretched when its + /// natural width reaches `1 − F` of the line. `0.25` is abcm2ps's — and CeolKit's — + /// default; `0` never stretches it, `1` always does. The spec's logical form and the + /// deprecated `%%ceolkit:justifylast` are `1` for true and `0` for false. + case stretchLast(Double) // %%stretchlast F (0…1) | true|false | %%ceolkit:justifylast + /// Whether systems are stretched to the line at all, from `%%stretchstaff` (ABC v2.2 + /// §11.4.3 lists it; abcm2ps defaults it to true). `false` draws every system at its + /// natural width, the last one included. + case stretchStaff(Bool) // %%stretchstaff true|false case label(String) // %%ceolkit:label "" (value of a `${label}` footer mark) case writeFields(String, Bool) // %%writefields [true|false] (ABC v2.2 §11.4.6) case dateFormat(String) // %%dateformat (abcm2ps/abc2svg) diff --git a/Sources/CeolKitModel/Diagnostic.swift b/Sources/CeolKitModel/Diagnostic.swift index 0271ffc..70c373f 100644 --- a/Sources/CeolKitModel/Diagnostic.swift +++ b/Sources/CeolKitModel/Diagnostic.swift @@ -76,6 +76,9 @@ public enum DiagnosticCode: String, Codable, Sendable { /// would change scale from the next music line; that is not implemented.) case scaleAppliesToWholeTune case invalidGraceNoteSpacing + /// `%%stretchlast` whose value is neither a number from 0 to 1 nor a logical, or a + /// `%%ceolkit:justifylast` that is not `true` or `false`. + case invalidStretchLast /// A font directive (§11.4.2) whose payload is not ` []`: no name, a /// size that is not a positive number, or words after the size. case invalidFontDirective @@ -83,7 +86,8 @@ public enum DiagnosticCode: String, Codable, Sendable { case unknownDirective case redundantDirective /// A directive CeolKit still honours but is going to remove, with its replacement named - /// in the message — `%%ceolkit:scale`, which is `%%pagescale` (issue #203). + /// in the message — `%%ceolkit:scale`, which is `%%pagescale` (issue #203), and + /// `%%ceolkit:justifylast`, which is `%%stretchlast` (issue #198). case deprecatedDirective /// A `%%footer` template contains a `$`-token CeolKit does not substitute — an /// abcm2ps placeholder that is not implemented here, or a typo such as `$p`. The diff --git a/Sources/CeolKitParser/Semantic/SemanticPass.swift b/Sources/CeolKitParser/Semantic/SemanticPass.swift index 8725b3d..70f9a99 100644 --- a/Sources/CeolKitParser/Semantic/SemanticPass.swift +++ b/Sources/CeolKitParser/Semantic/SemanticPass.swift @@ -249,7 +249,8 @@ struct SemanticPass { || name == "dateformat" || name == "footer" || name == "straightflags" || name == "graceslurs" || name == "score" || name == "staves" || name == "newpage" - || name == "scale" || name == "pagescale" + || name == "scale" || name == "pagescale" || name == "stretchlast" + || name == "stretchstaff" || TextFontRole(rawValue: name) != nil } @@ -1071,7 +1072,8 @@ struct SemanticPass { beforeStave: ctx.currentStaveIndex, restartingAt: parseNewPage(payload, source: source, diagnostics: &diagnostics), source: source)) - case "landscape", "flatbeams", "ceolkit:justifylast", "ceolkit:scale", + case "landscape", "flatbeams", "ceolkit:justifylast", "stretchlast", "stretchstaff", + "ceolkit:scale", "scale", "pagescale", "ceolkit:gracenotespacing", "ceolkit:fontlist", "writefields", "dateformat", "footer", "straightflags", "graceslurs", _ where TextFontRole(rawValue: name) != nil: @@ -1549,11 +1551,39 @@ struct SemanticPass { diagnostics.append(Diagnostic(severity: .warning, code: .unknownDirective, message: "%%flatbeams expects '0'/'false' or '1'/'true'", source: source)) return nil - case "ceolkit:justifylast": - if let value = parseLogical(trimmed) { return .justifyLast(value) } + case "stretchlast": + // abcm2ps takes a fraction of the line and rejects anything outside 0…1; the + // ABC v2.2 spec's list of directives gives it a logical, which is the two ends of + // that range. A number is tried first, so `0` and `1` mean the same either way. + if let f = Double(trimmed), f.isFinite { + guard (0...1).contains(f) else { + diagnostics.append(Diagnostic(severity: .warning, code: .invalidStretchLast, + message: "%%stretchlast must be between 0 and 1 (got \(trimmed))", + source: source)) + return nil + } + return .stretchLast(f) + } + if let value = parseLogical(trimmed) { return .stretchLast(value ? 1 : 0) } + diagnostics.append(Diagnostic(severity: .warning, code: .invalidStretchLast, + message: "%%stretchlast expects a number from 0 to 1, or 'true'/'false' " + + "(got '\(trimmed)')", source: source)) + return nil + case "stretchstaff": + if let value = parseLogical(trimmed) { return .stretchStaff(value) } diagnostics.append(Diagnostic(severity: .warning, code: .unknownDirective, - message: "%%ceolkit:justifylast expects 'true' or 'false'", source: source)) + message: "%%stretchstaff expects '0'/'false' or '1'/'true'", source: source)) return nil + case "ceolkit:justifylast": + guard let value = parseLogical(trimmed) else { + diagnostics.append(Diagnostic(severity: .warning, code: .invalidStretchLast, + message: "%%ceolkit:justifylast expects 'true' or 'false'", source: source)) + return nil + } + diagnostics.append(Diagnostic(severity: .warning, code: .deprecatedDirective, + message: "%%ceolkit:justifylast is deprecated; use %%stretchlast \(value ? 1 : 0), " + + "which it now means", source: source)) + return .stretchLast(value ? 1 : 0) case "ceolkit:fontlist": if trimmed.isEmpty { return .fontList(.resolved) } if let mode = FontListMode(rawValue: trimmed.lowercased()) { return .fontList(mode) } diff --git a/Sources/CeolKitSVGRenderer/CeolKitSVGRenderer.swift b/Sources/CeolKitSVGRenderer/CeolKitSVGRenderer.swift index 133dc12..3d47aac 100644 --- a/Sources/CeolKitSVGRenderer/CeolKitSVGRenderer.swift +++ b/Sources/CeolKitSVGRenderer/CeolKitSVGRenderer.swift @@ -384,7 +384,7 @@ public struct SVGRenderer: CeolKitRenderer { } let tuneGroups = groups.isEmpty ? [] : justifier.justifyGroups( groups, usableWidth: usableWidth(on: pageSizes.opening), - justifyLastSystem: layout.justifyLastSystem, + stretchLast: layout.stretchLast, stretchStaff: layout.stretchStaff, systemHeaderWidths: headerWidths, systemUsableWidths: usableWidthOfGroup) // Build the title block for this tune per §6.1.3. diff --git a/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift b/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift index 65da5e3..e533e73 100644 --- a/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift +++ b/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift @@ -38,7 +38,15 @@ public struct SVGRenderConfig: Sendable { public var spanStaffGap: Double /// Vertical gap added after the last system of a tune, before the next tune's title block. public var tuneGap: Double - public var justifyLastSystem: Bool + /// abcm2ps's `%%stretchlast`, from 0 to 1: the last system of a tune is stretched to the + /// full line when its natural width reaches `1 − stretchLast` of it (issue #198). The + /// default, `0.25`, is abcm2ps's. `0` leaves every last system at its natural width; `1` + /// stretches every one. A `%%stretchlast` in the document overrides it. + public var stretchLast: Double + /// abcm2ps's `%%stretchstaff`: whether systems are stretched to the line at all. `false` + /// draws every system at its natural width, the last one included; a system that + /// overruns is still compressed to fit. A `%%stretchstaff` in the document overrides it. + public var stretchStaff: Bool public var straightFlags: Bool public var graceSlurs: Bool /// Step between adjacent grace noteheads within one grace group, as a multiple of the @@ -86,7 +94,8 @@ public struct SVGRenderConfig: Sendable { staffGap: Double? = nil, spanStaffGap: Double? = nil, tuneGap: Double? = nil, - justifyLastSystem: Bool = false, + stretchLast: Double = 0.25, + stretchStaff: Bool = true, straightFlags: Bool = false, graceSlurs: Bool = true, graceNoteSpacing: Double = 1.05, @@ -105,7 +114,8 @@ public struct SVGRenderConfig: Sendable { self.staffGap = staffGap ?? staffSize * 3 self.spanStaffGap = spanStaffGap ?? staffSize * 2 self.tuneGap = tuneGap ?? staffSize * 16 - self.justifyLastSystem = justifyLastSystem + self.stretchLast = min(max(stretchLast, 0), 1) + self.stretchStaff = stretchStaff self.straightFlags = straightFlags self.graceSlurs = graceSlurs self.graceNoteSpacing = graceNoteSpacing diff --git a/Sources/CeolKitSVGRenderer/Layout/Justifier.swift b/Sources/CeolKitSVGRenderer/Layout/Justifier.swift index b49c993..7fd03a5 100644 --- a/Sources/CeolKitSVGRenderer/Layout/Justifier.swift +++ b/Sources/CeolKitSVGRenderer/Layout/Justifier.swift @@ -1,5 +1,6 @@ /// Pass 3: distributes horizontal slack across measures so each non-last system -/// fills the full usable line width. +/// fills the full usable line width — and the last one too, once it is full enough +/// (`%%stretchlast`, issue #198). /// /// Two things stop that from being unconditional. A system whose music overruns the line — /// which the `LineBreaker` now allows within its overflow tolerance — is compressed to fit @@ -32,19 +33,24 @@ public struct Justifier: Sendable { /// - Parameters: /// - systems: Pass 2 output. /// - usableWidth: Full available horizontal space (page width minus margins). - /// - justifyLastSystem: When `true`, the last system is also stretched to fill the line. + /// - stretchLast: abcm2ps's `%%stretchlast`: the last system is stretched too when its + /// natural width reaches `1 − stretchLast` of the line; see + /// ``stretchesLast(naturalWidth:lineWidth:headerWidth:stretchLast:)``. + /// - stretchStaff: abcm2ps's `%%stretchstaff`. `false` stretches no system at all. /// - systemHeaderWidths: Per-system width consumed by clef/key/time-sig headers. /// The target width for system `i` is `usableWidth - systemHeaderWidths[i]`. /// Defaults to zero for any system not covered by the array. public func justify( _ systems: [System], usableWidth: Double, - justifyLastSystem: Bool, + stretchLast: Double, + stretchStaff: Bool = true, systemHeaderWidths: [Double] = [] ) -> [JustifiedSystem] { justifyGroups(systems.map { SystemGroup(staves: [$0]) }, usableWidth: usableWidth, - justifyLastSystem: justifyLastSystem, + stretchLast: stretchLast, + stretchStaff: stretchStaff, systemHeaderWidths: systemHeaderWidths) .map { $0.staves[0] } } @@ -59,7 +65,10 @@ public struct Justifier: Sendable { /// - Parameters: /// - groups: Pass 2 output. /// - usableWidth: Full available horizontal space (page width minus margins). - /// - justifyLastSystem: When `true`, the last system is also stretched to fill the line. + /// - stretchLast: abcm2ps's `%%stretchlast`: the last system is stretched too when its + /// natural width reaches `1 − stretchLast` of the line; see + /// ``stretchesLast(naturalWidth:lineWidth:headerWidth:stretchLast:)``. + /// - stretchStaff: abcm2ps's `%%stretchstaff`. `false` stretches no system at all. /// - systemHeaderWidths: Per-system width consumed by clef/key/time-sig headers — /// already the `max` across the group's voices, since its staves start at a common x. /// - systemUsableWidths: Per-system line width, where the systems of one tune do not all @@ -73,31 +82,85 @@ public struct Justifier: Sendable { public func justifyGroups( _ groups: [SystemGroup], usableWidth: Double, - justifyLastSystem: Bool, + stretchLast: Double, + stretchStaff: Bool = true, systemHeaderWidths: [Double] = [], systemUsableWidths: [Double] = [] ) -> [JustifiedSystemGroup] { - groups.enumerated().map { i, group in + // How far the line before was stretched, as a multiple of its natural width — what a + // last system left short is spaced at. `1` before the first: natural spacing. + var previousSpread = 1.0 + return groups.enumerated().map { i, group in let headerWidth = i < systemHeaderWidths.count ? systemHeaderWidths[i] : 0 let lineWidth = i < systemUsableWidths.count ? systemUsableWidths[i] : usableWidth let targetWidth = lineWidth - headerWidth - let shouldStretch = !group.isLastSystem || justifyLastSystem - return justify(group, targetWidth: targetWidth, stretch: shouldStretch, - capStretch: group.staveWasSplit) + let naturalTotal = Self.naturalWidth(of: group) + let finalTotal: Double + if !stretchStaff { + // `%%stretchstaff 0`: every system natural, and only an overrun squeezed. + finalTotal = min(naturalTotal, targetWidth) + } else if !group.isLastSystem + || Self.stretchesLast(naturalWidth: naturalTotal, lineWidth: lineWidth, + headerWidth: headerWidth, stretchLast: stretchLast) { + finalTotal = resolvedWidth(naturalTotal: naturalTotal, targetWidth: targetWidth, + capStretch: group.staveWasSplit) + } else { + finalTotal = shortLastWidth(naturalTotal: naturalTotal, targetWidth: targetWidth, + previousSpread: previousSpread, + capStretch: group.staveWasSplit) + } + if naturalTotal > 0 { previousSpread = finalTotal / naturalTotal } + return justify(group, finalTotal: finalTotal) } } + /// Whether the last system of a tune is stretched to the line, as abcm2ps decides it: + /// when the staff it would draw unstretched — header and music — reaches `1 − F` of the + /// line, for `%%stretchlast F`. + /// + /// abcm2ps 8.14.0, on a 680 pt line at the default `0.25`, leaves a last line filling + /// 0.69 of it short and stretches one filling 0.75; at `0.6` it stretches one filling + /// 0.52. `0` stretches no last line to the full width, `1` stretches every one. A last + /// line that is not stretched is still not drawn at its natural width; see + /// ``shortLastWidth(naturalTotal:targetWidth:previousSpread:capStretch:)``. + static func stretchesLast(naturalWidth: Double, lineWidth: Double, headerWidth: Double, + stretchLast: Double) -> Bool { + guard stretchLast > 0 else { return false } + return headerWidth + naturalWidth >= (1 - stretchLast) * lineWidth + } + + /// A system's natural width: each column as wide as its widest staff needs. + static func naturalWidth(of group: SystemGroup) -> Double { + (0.. Double { + var width = naturalTotal * max(1, previousSpread) + if capStretch { width = min(width, naturalTotal * maxStretch) } + return min(width, targetWidth) + } + // MARK: - Private - private func justify(_ group: SystemGroup, targetWidth: Double, stretch: Bool, - capStretch: Bool) -> JustifiedSystemGroup { + private func justify(_ group: SystemGroup, finalTotal: Double) -> JustifiedSystemGroup { // A column is as wide as its widest staff needs; every staff is then drawn to that. let columnWidths = (0.. Double { + capStretch: Bool) -> Double { if naturalTotal > targetWidth { return targetWidth } - guard stretch else { return naturalTotal } guard capStretch else { return targetWidth } return min(targetWidth, naturalTotal * maxStretch) } diff --git a/Sources/CeolKitSVGRenderer/Layout/LayoutDirectives.swift b/Sources/CeolKitSVGRenderer/Layout/LayoutDirectives.swift index 0027805..f8d19a8 100644 --- a/Sources/CeolKitSVGRenderer/Layout/LayoutDirectives.swift +++ b/Sources/CeolKitSVGRenderer/Layout/LayoutDirectives.swift @@ -14,9 +14,11 @@ struct LayoutDirectives { /// choice to the note's staff position, which is the ordinary engraving rule. A voice's /// own `V:` `stem=` outranks it; the emitter resolves the two per staff (issue #74). var stemDirection: StemDirection = .auto - /// Whether the last system of a tune is stretched to the full measure, from - /// `%%ceolkit:justifylast`. - var justifyLastSystem: Bool + /// How full the last system of a tune must be to be stretched to the line, from + /// `%%stretchlast` (or the deprecated `%%ceolkit:justifylast`). + var stretchLast: Double + /// Whether systems are stretched to the line at all, from `%%stretchstaff`. + var stretchStaff: Bool /// The page scale, as abcm2ps's `%%scale` value: ``SVGRenderConfig/scale`` unless /// `%%scale`, `%%pagescale` or `%%ceolkit:scale` says otherwise (issue #203). var scale: Double @@ -49,7 +51,8 @@ struct LayoutDirectives { /// The document baseline before any directive has been read: what the config asks for. init(config: SVGRenderConfig) { fonts = config.textFonts - justifyLastSystem = config.justifyLastSystem + stretchLast = config.stretchLast + stretchStaff = config.stretchStaff scale = config.scale graceNoteSpacing = config.graceNoteSpacing straightFlags = config.straightFlags @@ -65,7 +68,8 @@ struct LayoutDirectives { // saying `pipeformat false` under a preamble saying `true` is asking for the // ordinary pitch rule, and nothing else in the tune can ask for it. case .pipeFormat(let on): stemDirection = on ? .down : .auto - case .justifyLast(let on): justifyLastSystem = on + case .stretchLast(let fraction): stretchLast = fraction + case .stretchStaff(let on): stretchStaff = on case .scale(let factor): scale = factor case .graceNoteSpacing(let step): graceNoteSpacing = step case .straightFlags(let on): straightFlags = on diff --git a/Sources/ckprobe/Options.swift b/Sources/ckprobe/Options.swift index b48a7f5..cf22e59 100644 --- a/Sources/ckprobe/Options.swift +++ b/Sources/ckprobe/Options.swift @@ -20,7 +20,7 @@ struct Options { var graceSpacing: Double? /// Render once per factor and print a systems/pages table instead of a full report. var sweep: [Double]? - /// Force `%%ceolkit:justifylast false` so reported widths are natural, not stretched. + /// Force `%%stretchstaff 0` so reported widths are natural, not stretched. var natural: Bool = false /// Directory to write `page0.svg`, `page1.svg`, … into. var outputDirectory: URL? @@ -48,7 +48,7 @@ struct Options { --grace-spacing Override %%ceolkit:gracenotespacing (>= 1) before rendering. --sweep Render at each %%scale; print a systems/pages table. - --natural Force %%ceolkit:justifylast false, so system widths + --natural Force %%stretchstaff 0, so system widths are reported unstretched. --out Write page0.svg, page1.svg, … into . --json Emit JSON instead of the text report. diff --git a/Sources/ckprobe/SourceRewriter.swift b/Sources/ckprobe/SourceRewriter.swift index d47ead6..eef16c7 100644 --- a/Sources/ckprobe/SourceRewriter.swift +++ b/Sources/ckprobe/SourceRewriter.swift @@ -56,7 +56,7 @@ enum SourceRewriter { result = overriding("gracenotespacing", with: String(graceSpacing), in: result) } if options.natural { - result = overriding("justifylast", with: "false", in: result) + result = overriding(["stretchstaff"], with: "%%stretchstaff 0", in: result) } return result } diff --git a/Tests/CeolKitParserTests/Extensions/CeolKitExtensionTests.swift b/Tests/CeolKitParserTests/Extensions/CeolKitExtensionTests.swift index 4c2f662..4e8b14c 100644 --- a/Tests/CeolKitParserTests/Extensions/CeolKitExtensionTests.swift +++ b/Tests/CeolKitParserTests/Extensions/CeolKitExtensionTests.swift @@ -1,6 +1,6 @@ // CeolKit extension directive conformance tests. // Tests %%ceolkit:pipeformat, %%ceolkit:pagenumber, %%ceolkit:stemalignment, -// %%ceolkit:justifylast, %%scale / %%pagescale / %%ceolkit:scale. +// %%stretchlast / %%ceolkit:justifylast, %%scale / %%pagescale / %%ceolkit:scale. // See EXTENSIONS.md and CeolKit spec §7. import Testing import CeolKitModel @@ -296,62 +296,69 @@ struct CeolKitExtensionTests { #expect(!warnings.isEmpty) } - // MARK: %%ceolkit:justifylast + // MARK: %%stretchlast and %%ceolkit:justifylast (issue #198) - @Test("%%ceolkit:justifylast true attaches justifyLast(true) directive") - func justifylastTrue() { - let abc = """ - X:1 - T:Test - M:4/4 - L:1/4 - %%ceolkit:justifylast true - K:G - GABC| - """ - let result = parse(abc) - let tune = result.score.firstTune - let directive = tune?.directives.first(where: { - if case .justifyLast = $0.directive { return true } - return false - }) - #expect(directive != nil) - if case .justifyLast(let value) = directive?.directive { - #expect(value == true) + /// The `%%stretchlast` values attached to the first tune, in order. + private func stretchLastValues(_ abc: String) -> [Double] { + (parse(abc).score.firstTune?.directives ?? []).compactMap { + if case .stretchLast(let value) = $0.directive { return value } + return nil } } - @Test("%%ceolkit:justifylast false attaches justifyLast(false) directive") - func justifylastFalse() { - let abc = """ - X:1 - T:Test - M:4/4 - L:1/4 - %%ceolkit:justifylast false - K:G - GABC| - """ - let result = parse(abc) - let tune = result.score.firstTune - let directive = tune?.directives.first(where: { - if case .justifyLast = $0.directive { return true } - return false - }) - #expect(directive != nil) - if case .justifyLast(let value) = directive?.directive { - #expect(value == false) - } + private func tune(header line: String) -> String { + "X:1\nT:Test\nM:4/4\nL:1/4\n\(line)\nK:G\nGABC|" + } + + @Test("%%stretchlast takes abcm2ps's fraction of the line", + arguments: [("0", 0.0), ("0.25", 0.25), ("0.6", 0.6), ("1", 1.0)]) + func stretchLastFraction(payload: String, expected: Double) { + #expect(stretchLastValues(tune(header: "%%stretchlast \(payload)")) == [expected]) + } + + @Test("%%stretchlast takes the spec's logical as the two ends of the range", + arguments: [("true", 1.0), ("false", 0.0)]) + func stretchLastLogical(payload: String, expected: Double) { + #expect(stretchLastValues(tune(header: "%%stretchlast \(payload)")) == [expected]) + } + + @Test("%%stretchlast outside 0…1, or not a number, is dropped with a warning", + arguments: ["1.5", "-0.2", "yes", "0.3x"]) + func stretchLastInvalid(payload: String) { + let abc = tune(header: "%%stretchlast \(payload)") + #expect(stretchLastValues(abc).isEmpty) + let warnings = parse(abc).score.diagnostics.filter { $0.code == .invalidStretchLast } + #expect(warnings.count == 1) + } + + @Test("%%ceolkit:justifylast is a deprecated %%stretchlast 1 or 0", + arguments: [("true", 1.0), ("false", 0.0)]) + func justifyLastIsDeprecatedStretchLast(payload: String, expected: Double) { + let abc = tune(header: "%%ceolkit:justifylast \(payload)") + #expect(stretchLastValues(abc) == [expected]) + let deprecations = parse(abc).score.diagnostics.filter { $0.code == .deprecatedDirective } + #expect(deprecations.count == 1) + #expect(deprecations.first?.message.contains("%%stretchlast \(Int(expected))") == true) + } + + @Test("%%stretchstaff takes a logical", arguments: [("1", true), ("false", false)]) + func stretchStaffLogical(payload: String, expected: Bool) { + let values = (parse(tune(header: "%%stretchstaff \(payload)")).score.firstTune? + .directives ?? []).compactMap { scope -> Bool? in + if case .stretchStaff(let on) = scope.directive { return on } + return nil + } + #expect(values == [expected]) } @Test("%%ceolkit:justifylast with invalid payload emits warning and drops directive") func justifylastInvalidPayloadEmitsWarning() { let abc = "%%ceolkit:justifylast yes\nX:1\nT:T\nM:4/4\nL:1/4\nK:C\nC|" let result = parse(abc) - let warnings = result.score.diagnostics.filter { $0.code == .unknownDirective } + let warnings = result.score.diagnostics.filter { $0.code == .invalidStretchLast } #expect(!warnings.isEmpty) let hasDirective = result.score.tunes.flatMap(\.directives).contains { - if case .justifyLast = $0.directive { return true } + if case .stretchLast = $0.directive { return true } return false } #expect(!hasDirective) diff --git a/Tests/CeolKitSVGRendererTests/DirectiveScopeTests.swift b/Tests/CeolKitSVGRendererTests/DirectiveScopeTests.swift index 2a985f3..0131f19 100644 --- a/Tests/CeolKitSVGRendererTests/DirectiveScopeTests.swift +++ b/Tests/CeolKitSVGRendererTests/DirectiveScopeTests.swift @@ -200,7 +200,7 @@ struct DirectiveScopeTests { #expect(abs(beams[2] - beams[0]) < 1e-9) } - // MARK: - %%ceolkit:justifylast + // MARK: - %%stretchlast /// A one-system tune is all last system, so justifying it stretches the staff to the /// full usable width and leaving it alone stops it at the music's natural width. @@ -208,31 +208,31 @@ struct DirectiveScopeTests { config.pageSize.width - config.margins.right } - @Test("A tune header %%ceolkit:justifylast does not stretch the tune after it") - func justifyLastDoesNotLeakForward() throws { + @Test("A tune header %%stretchlast does not stretch the tune after it") + func stretchLastDoesNotLeakForward() throws { let config = SVGRenderConfig() - let runs = staffRuns(in: try render(tunes(["%%ceolkit:justifylast true", nil]), + let runs = staffRuns(in: try render(tunes(["%%stretchlast 1", nil]), config: config)) try #require(runs.count == 2) #expect(abs(runs[0].rightX - justifiedRightX(config)) < 1e-6) #expect(runs[1].rightX < justifiedRightX(config) - 1) } - @Test("A preamble %%ceolkit:justifylast governs every tune") - func preambleJustifyLastGovernsDocument() throws { + @Test("A preamble %%stretchlast governs every tune") + func preambleStretchLastGovernsDocument() throws { let config = SVGRenderConfig() let runs = staffRuns(in: try render( - tunes(preamble: "%%ceolkit:justifylast true", [nil, nil]), config: config)) + tunes(preamble: "%%stretchlast 1", [nil, nil]), config: config)) try #require(runs.count == 2) #expect(runs.allSatisfy { abs($0.rightX - justifiedRightX(config)) < 1e-6 }) } - @Test("A tune turning a preamble %%ceolkit:justifylast off does not keep it off after") - func justifyLastOverrideIsNotSticky() throws { + @Test("A tune turning a preamble %%stretchlast off does not keep it off after") + func stretchLastOverrideIsNotSticky() throws { let config = SVGRenderConfig() let runs = staffRuns(in: try render( - tunes(preamble: "%%ceolkit:justifylast true", - [nil, "%%ceolkit:justifylast false", nil]), config: config)) + tunes(preamble: "%%stretchlast 1", + [nil, "%%stretchlast 0", nil]), config: config)) try #require(runs.count == 3) #expect(abs(runs[0].rightX - justifiedRightX(config)) < 1e-6) #expect(runs[1].rightX < justifiedRightX(config) - 1) diff --git a/Tests/CeolKitSVGRendererTests/EventVoiceTagTests.swift b/Tests/CeolKitSVGRendererTests/EventVoiceTagTests.swift index d18cd10..03a19df 100644 --- a/Tests/CeolKitSVGRendererTests/EventVoiceTagTests.swift +++ b/Tests/CeolKitSVGRendererTests/EventVoiceTagTests.swift @@ -66,7 +66,7 @@ struct EventVoiceTagTests { let system = System(measures: [sized], isLastSystem: false, sourceForced: false) let justified = Justifier().justify([system], usableWidth: sized.naturalWidth * 2, - justifyLastSystem: true) + stretchLast: 1) let jm = justified[0].measures[0] #expect(jm.eventOffsets != sized.eventOffsets) // it really did stretch @@ -129,7 +129,7 @@ struct EventVoiceTagTests { let lines = voices.indices.map { LineBreaker.VoiceLine(measures: columnsPerVoice[$0]) } let groups = LineBreaker().breakIntoGroups(lines, breaks: breaks, usableWidth: 500) - let justified = Justifier().justifyGroups(groups, usableWidth: 500, justifyLastSystem: false) + let justified = Justifier().justifyGroups(groups, usableWidth: 500, stretchLast: 0) let block = TuneBlock(systemGroups: justified) let layout = VerticalLayoutEngine(config: config, metadata: metadata).layout([block]) diff --git a/Tests/CeolKitSVGRendererTests/JustifierTests.swift b/Tests/CeolKitSVGRendererTests/JustifierTests.swift index 5e05592..c4095d4 100644 --- a/Tests/CeolKitSVGRendererTests/JustifierTests.swift +++ b/Tests/CeolKitSVGRendererTests/JustifierTests.swift @@ -42,7 +42,7 @@ private let usableWidth: Double = 300 // Non-last system: final widths sum to exactly usableWidth. @Test func nonLastSystemFillsUsableWidth() { let system = makeSystem(widths: [80, 100, 60], isLast: false) - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) let totalFinal = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(totalFinal - usableWidth) < 1e-9) } @@ -50,34 +50,86 @@ private let usableWidth: Double = 300 // Non-last system: every measure's finalWidth ≥ naturalWidth. @Test func nonLastSystemNeverShrinksAMeasure() { let system = makeSystem(widths: [80, 100, 60], isLast: false) - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) for (jm, sm) in zip(result[0].measures, system.measures) { #expect(jm.finalWidth >= sm.naturalWidth) } } - // Last system without justifyLastSystem: finalWidth == naturalWidth for each measure. + // Last system with stretchLast 0: finalWidth == naturalWidth for each measure. @Test func lastSystemUnchangedWhenNotJustified() { let system = makeSystem(widths: [80, 100, 60], isLast: true) - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) for (jm, sm) in zip(result[0].measures, system.measures) { #expect(abs(jm.finalWidth - sm.naturalWidth) < 1e-9) } } - // Last system with justifyLastSystem == true: sum equals usableWidth. + // Last system with stretchLast 1: sum equals usableWidth. @Test func lastSystemFilledWhenJustifyLastEnabled() { let system = makeSystem(widths: [80, 100, 60], isLast: true) - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: true) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 1) let totalFinal = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(totalFinal - usableWidth) < 1e-9) } + // %%stretchlast F (issue #198): as abcm2ps does, the last system is stretched when its + // staff — header and music — reaches 1 − F of the line, and otherwise left short. + @Test("The last system is stretched once it fills 1 − stretchLast of the line", + arguments: [ + // (music widths, header, stretchLast, stretched?) + ([100.0, 100.0], 0.0, 0.25, false), // 0.67 of the line + ([100.0, 125.0], 0.0, 0.25, true), // exactly 0.75 + ([100.0, 100.0], 25.0, 0.25, true), // the header counts towards the fill + ([100.0, 100.0], 0.0, 0.6, true), // 0.67 ≥ 0.4 + ([10.0], 0.0, 1.0, true), // 1 stretches every last system + ([290.0], 0.0, 0.0, false), // 0 stretches none + ]) + func lastSystemStretchThreshold(widths: [Double], header: Double, stretchLast: Double, + stretched: Bool) { + let system = makeSystem(widths: widths, isLast: true) + let result = justifier.justify([system], usableWidth: usableWidth, + stretchLast: stretchLast, systemHeaderWidths: [header]) + let totalFinal = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } + let expected = stretched ? usableWidth - header : widths.reduce(0, +) + #expect(abs(totalFinal - expected) < 1e-9) + } + + // %%stretchstaff 0: no system is stretched, the last one included; an overrun is still + // squeezed onto the line. + @Test func stretchStaffOffLeavesEverySystemNatural() { + let systems = [makeSystem(widths: [100, 100], isLast: false), + makeSystem(widths: [200, 150], isLast: false), + makeSystem(widths: [280], isLast: true)] + let result = justifier.justify(systems, usableWidth: usableWidth, stretchLast: 1, + stretchStaff: false) + let totals = result.map { $0.measures.reduce(0.0) { $0 + $1.finalWidth } } + #expect(totals == [200, 300, 280]) + } + + // A last system too short to stretch is spaced as the line before it was, as abcm2ps + // does (issue #198) — never below natural, never past the line. + @Test("A short last system takes the previous line's spacing", + arguments: [ + // (previous line's widths, last line's widths, last line's expected width) + ([100.0, 100.0], [60.0], 90.0), // previous stretched 1.5× + ([150.0, 200.0], [60.0], 60.0), // previous compressed: natural + ([20.0], [100.0], 300.0), // 15× would overrun: the line + ]) + func shortLastSystemFollowsPreviousSpacing(previous: [Double], last: [Double], + expected: Double) { + let systems = [makeSystem(widths: previous, isLast: false), + makeSystem(widths: last, isLast: true)] + let result = justifier.justify(systems, usableWidth: usableWidth, stretchLast: 0) + let lastTotal = result[1].measures.reduce(0.0) { $0 + $1.finalWidth } + #expect(abs(lastTotal - expected) < 1e-9) + } + // eventOffsets are rescaled proportionally to finalWidth / naturalWidth. @Test func eventOffsetsRescaledProportionally() { let sized = sizedMeasure(width: 100, offsets: [0, 25, 50, 75]) let system = System(measures: [sized], isLastSystem: false, sourceForced: false) - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) let jm = result[0].measures[0] let scale = jm.finalWidth / sized.naturalWidth let expected = sized.eventOffsets.map { $0 * scale } @@ -92,7 +144,7 @@ private let usableWidth: Double = 300 makeSystem(widths: [100, 100], isLast: false, sourceForced: true), makeSystem(widths: [80], isLast: true, sourceForced: false), ] - let result = justifier.justify(systems, usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify(systems, usableWidth: usableWidth, stretchLast: 0) #expect(result[0].sourceForced == true) #expect(result[0].isLastSystem == false) #expect(result[1].sourceForced == false) @@ -101,7 +153,7 @@ private let usableWidth: Double = 300 // Empty system list produces empty output. @Test func emptyInputProducesEmptyOutput() { - let result = justifier.justify([], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([], usableWidth: usableWidth, stretchLast: 0) #expect(result.isEmpty) } @@ -113,7 +165,7 @@ private let usableWidth: Double = 300 // 60 pt of music on a 300 pt line would need 5×; the cap is 3×. let system = makeSystem(widths: [60], isLast: false, staveWasSplit: true) let result = Justifier(maxStretch: 3.0) - .justify([system], usableWidth: usableWidth, justifyLastSystem: false) + .justify([system], usableWidth: usableWidth, stretchLast: 0) let total = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(total - 180.0) < 1e-9) } @@ -122,7 +174,7 @@ private let usableWidth: Double = 300 @Test func splitStaveSystemUnderTheCapStillFillsTheLine() { let system = makeSystem(widths: [120, 60], isLast: false, staveWasSplit: true) let result = Justifier(maxStretch: 3.0) - .justify([system], usableWidth: usableWidth, justifyLastSystem: false) + .justify([system], usableWidth: usableWidth, stretchLast: 0) let total = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(total - usableWidth) < 1e-9) } @@ -132,7 +184,7 @@ private let usableWidth: Double = 300 @Test func sourceBrokenSystemIsNotCapped() { let system = makeSystem(widths: [60], isLast: false, sourceForced: true, staveWasSplit: false) let result = Justifier(maxStretch: 3.0) - .justify([system], usableWidth: usableWidth, justifyLastSystem: false) + .justify([system], usableWidth: usableWidth, stretchLast: 0) let total = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(total - usableWidth) < 1e-9) } @@ -143,7 +195,7 @@ private let usableWidth: Double = 300 // even when it is the last system and would otherwise keep natural spacing. @Test func overrunningLastSystemIsCompressedToFit() { let system = makeSystem(widths: [153, 150], isLast: true) // 303 pt on a 300 pt line - let result = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) let total = result[0].measures.reduce(0.0) { $0 + $1.finalWidth } #expect(abs(total - usableWidth) < 1e-9) // Compression is shared out in proportion to natural width. @@ -160,7 +212,7 @@ private let usableWidth: Double = 300 makeSystem(widths: [50, 120, 90], isLast: false), ]) let result = justifier.justifyGroups([group], usableWidth: usableWidth, - justifyLastSystem: false) + stretchLast: 0) #expect(result[0].staves.count == 2) #expect(result[0].staves[0].measures.map(\.finalWidth) == result[0].staves[1].measures.map(\.finalWidth)) @@ -179,7 +231,7 @@ private let usableWidth: Double = 300 makeSystem(widths: [140, 10], isLast: false), ]) let result = justifier.justifyGroups([group], usableWidth: usableWidth, - justifyLastSystem: false) + stretchLast: 0) let widths = result[0].staves[0].measures.map(\.finalWidth) #expect(abs(widths[0] - widths[1]) < 1e-9) } @@ -188,8 +240,8 @@ private let usableWidth: Double = 300 @Test func groupOfOneMatchesTheSingleSystemPath() { let system = makeSystem(widths: [80, 100, 60], isLast: false) let viaGroups = justifier.justifyGroups([SystemGroup(staves: [system])], - usableWidth: usableWidth, justifyLastSystem: false) - let direct = justifier.justify([system], usableWidth: usableWidth, justifyLastSystem: false) + usableWidth: usableWidth, stretchLast: 0) + let direct = justifier.justify([system], usableWidth: usableWidth, stretchLast: 0) #expect(viaGroups[0].staves[0].measures.map(\.finalWidth) == direct[0].measures.map(\.finalWidth)) } @@ -205,7 +257,7 @@ private let usableWidth: Double = 300 let naturalGap = 10.0 let sized = sizedMeasure(width: 40, offsets: [7, 17, 27], graceEventIndices: [0]) let system = System(measures: [sized], isLastSystem: false, sourceForced: false) - let result = justifier.justify([system], usableWidth: 60, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: 60, stretchLast: 0) let jm = result[0].measures[0] let stretchedGap = jm.eventOffsets[1] - jm.eventOffsets[0] @@ -221,7 +273,7 @@ private let usableWidth: Double = 300 // new offset[2] = 7 + 10 + (27 - 7 - 10) * (43/23) = 17 + 10*(43/23) let sized = sizedMeasure(width: 40, offsets: [7, 17, 27], graceEventIndices: [0]) let system = System(measures: [sized], isLastSystem: false, sourceForced: false) - let result = justifier.justify([system], usableWidth: 60, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: 60, stretchLast: 0) let jm = result[0].measures[0] let elasticScale = (60.0 - 7.0 - 10.0) / (40.0 - 7.0 - 10.0) // 43/23 @@ -242,7 +294,7 @@ private let usableWidth: Double = 300 let sized = sizedMeasure(width: 50, offsets: [7, 14, 24, 34, 39], graceEventIndices: [0, 3]) let system = System(measures: [sized], isLastSystem: false, sourceForced: false) - let result = justifier.justify([system], usableWidth: 70, justifyLastSystem: false) + let result = justifier.justify([system], usableWidth: 70, stretchLast: 0) let jm = result[0].measures[0] // Both fixed gaps must be preserved exactly. diff --git a/Tests/CeolKitSVGRendererTests/LineBreakBalanceTests.swift b/Tests/CeolKitSVGRendererTests/LineBreakBalanceTests.swift index 2da6ee8..6d1d2cf 100644 --- a/Tests/CeolKitSVGRendererTests/LineBreakBalanceTests.swift +++ b/Tests/CeolKitSVGRendererTests/LineBreakBalanceTests.swift @@ -17,7 +17,7 @@ import Testing private let splitLineTune = """ %abc-2.2 %%ceolkit:pipeformat true -%%ceolkit:justifylast true +%%stretchlast 1 %%landscape 0 X:1 T:Kalabakan (Borneo) @@ -73,7 +73,7 @@ struct LineBreakBalanceTests { meter: tune.meter) let headerWidths = systems.enumerated().map { i, _ in i == 0 ? firstHeaderW : laterHeaderW } let justified = justifier.justify(systems, usableWidth: usableWidth, - justifyLastSystem: true, + stretchLast: 1, systemHeaderWidths: headerWidths) let targets = headerWidths.map { usableWidth - $0 } return (systems, justified, targets) diff --git a/Tests/CeolKitSVGRendererTests/PageOrientationTests.swift b/Tests/CeolKitSVGRendererTests/PageOrientationTests.swift index 930e6fa..714502e 100644 --- a/Tests/CeolKitSVGRendererTests/PageOrientationTests.swift +++ b/Tests/CeolKitSVGRendererTests/PageOrientationTests.swift @@ -236,12 +236,12 @@ struct PageOrientationTests { @Test("The systems after a mid-tune turn are broken to the new page's width") func midTuneSystemsFillTheNewWidth() throws { - // `%%ceolkit:justifylast` so that both systems are stretched to their own page's + // `%%stretchlast 1` so that both systems are stretched to their own page's // line: without it the last system of the tune is left at its natural width, which // is the same music either side of the break and so says nothing about the width it // was given. let abc = """ - %%ceolkit:justifylast true + %%stretchlast 1 X:1 T:Only M:4/4 diff --git a/Tests/CeolKitSVGRendererTests/SharedStaffMergeTests.swift b/Tests/CeolKitSVGRendererTests/SharedStaffMergeTests.swift index fd8b6d7..8577dce 100644 --- a/Tests/CeolKitSVGRendererTests/SharedStaffMergeTests.swift +++ b/Tests/CeolKitSVGRendererTests/SharedStaffMergeTests.swift @@ -251,7 +251,7 @@ struct SharedStaffMergeTests { let system = System(measures: [merged], isLastSystem: false, sourceForced: false) let justified = Justifier().justify([system], usableWidth: merged.naturalWidth * 2, - justifyLastSystem: true) + stretchLast: 1) let stretched = justified[0].measures[0] #expect(stretched.eventOffsets != merged.eventOffsets) // it really did stretch diff --git a/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift b/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift index 2d12f96..1d4b951 100644 --- a/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift +++ b/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift @@ -132,7 +132,7 @@ struct StaffBraceTests { @Test("The music still ends on the right margin: the brace's indent came out of the line") func indentIsTakenOutOfTheLine() { var config = config - config.justifyLastSystem = true + config.stretchLast = 1 let staves = render(voices(2, "%%score {1 2}"), config: config).staves let rightMargin = config.pageSize.width - config.margins.right #expect(staves.allSatisfy { $0.left > config.margins.left }) diff --git a/Tests/CeolKitSVGRendererTests/StaffBracketTests.swift b/Tests/CeolKitSVGRendererTests/StaffBracketTests.swift index 137f83d..b98fea2 100644 --- a/Tests/CeolKitSVGRendererTests/StaffBracketTests.swift +++ b/Tests/CeolKitSVGRendererTests/StaffBracketTests.swift @@ -75,9 +75,9 @@ struct StaffBracketTests { @Test("The music still ends on the right margin: the indent came out of the line, not off the page") func indentIsTakenOutOfTheLine() { - // `justifyLastSystem` so the one system in each tune is stretched to the full line. + // `stretchLast` 1 so the one system in each tune is stretched to the full line. var config = config - config.justifyLastSystem = true + config.stretchLast = 1 let plain = render(threeVoices(), config: config).staves let bracketed = render(threeVoices("%%score [1 2 3]"), config: config).staves diff --git a/Tests/CeolKitSVGRendererTests/StaffPlanRegionTests.swift b/Tests/CeolKitSVGRendererTests/StaffPlanRegionTests.swift index f5f8d39..30906a6 100644 --- a/Tests/CeolKitSVGRendererTests/StaffPlanRegionTests.swift +++ b/Tests/CeolKitSVGRendererTests/StaffPlanRegionTests.swift @@ -226,7 +226,7 @@ struct StaffPlanRegionTests { @Test("Only the tune's very last system is the last one, however many regions there are") func onlyTheFinalRegionEndsTheTune() { - // `justifyLastSystem` is off by default, so only a system marked last is left short; + // A short system marked last is left short (`%%stretchlast` 0.25 by default); // a region boundary in the middle must not leave the system before it unstretched. let abc = threeVoices(header: "%%score [1 2 3]", body: "%%score [1 2]") let systems = render(abc).pages.flatMap(\.systems) diff --git a/Tests/CeolKitSVGRendererTests/StaffSpanThreadingTests.swift b/Tests/CeolKitSVGRendererTests/StaffSpanThreadingTests.swift index e74a2c9..eb80438 100644 --- a/Tests/CeolKitSVGRendererTests/StaffSpanThreadingTests.swift +++ b/Tests/CeolKitSVGRendererTests/StaffSpanThreadingTests.swift @@ -136,7 +136,7 @@ struct StaffSpanThreadingTests { #expect(groups.allSatisfy { $0.grouping?.spans == grouping.spans }) #expect(groups.allSatisfy { $0.grouping?.barlineJoins == grouping.barlineJoins }) - let justified = Justifier().justifyGroups(groups, usableWidth: 300, justifyLastSystem: false) + let justified = Justifier().justifyGroups(groups, usableWidth: 300, stretchLast: 0) #expect(justified.allSatisfy { $0.grouping?.spans == grouping.spans }) } diff --git a/Tests/CeolKitSVGRendererTests/StyleTests.swift b/Tests/CeolKitSVGRendererTests/StyleTests.swift index 7175e11..4899036 100644 --- a/Tests/CeolKitSVGRendererTests/StyleTests.swift +++ b/Tests/CeolKitSVGRendererTests/StyleTests.swift @@ -18,7 +18,7 @@ private func parse(_ source: String) -> ParseResult { private let multitune = """ %abc-2.2 %%ceolkit:pipeformat true -%%ceolkit:justifylast true +%%stretchlast 1 %%writefields TRCQ true %%footer " Generated: $D" %%dateformat "%e %B %Y %H:%M" @@ -42,11 +42,11 @@ K:Cmaj a g d A | """ -// Same tune as abcTune but with %%ceolkit:justifylast true added. +// Same tune as abcTune but with %%stretchlast 1 added. private let abcTuneJustifyLast = """ %abc-2.2 %%ceolkit:pipeformat true -%%ceolkit:justifylast true +%%stretchlast 1 %%writefields TRC true %%footer " Generated: $D" %%straightflags false @@ -206,7 +206,7 @@ struct StyleTests { let widths = systems.enumerated().map { i, _ in i == 0 ? firstHeaderW : laterHeaderW } let justified = justifier.justify(systems, usableWidth: usableWidth, - justifyLastSystem: config.justifyLastSystem, + stretchLast: config.stretchLast, systemHeaderWidths: widths) let allSystems = engine.layout(justified).pages.flatMap { $0.systems } @@ -230,15 +230,15 @@ struct StyleTests { } } - // MARK: - %%ceolkit:justifylast + // MARK: - %%stretchlast // Shared layout helper: sizes and breaks measures into justified systems. - // `justifyLast` is passed explicitly so callers can verify directive-driven behaviour. + // `stretchLast` is passed explicitly so callers can verify directive-driven behaviour. private func justifiedSystems( score: Score, config: SVGRenderConfig, metadata: BravuraMetadata, - justifyLast: Bool + stretchLast: Double ) throws -> (systems: [JustifiedSystem], usableWidth: Double, laterHeaderWidth: Double) { let tune = try #require(score.tunes.first) let voice = try #require(tune.voices.first) @@ -280,12 +280,12 @@ struct StyleTests { let widths = systems.enumerated().map { i, _ in i == 0 ? firstHeaderW : laterHeaderW } let justified = justifier.justify(systems, usableWidth: usableWidth, - justifyLastSystem: justifyLast, + stretchLast: stretchLast, systemHeaderWidths: widths) return (justified, usableWidth, laterHeaderW) } - // When %%ceolkit:justifylast true is present the last system must be stretched + // When %%stretchlast 1 is present the last system must be stretched // to fill the full usable line width, just like every other system. @Test func lastSystemIsJustifiedWhenDirectiveIsTrue() throws { let metadata = try BravuraMetadata.load() @@ -294,22 +294,22 @@ struct StyleTests { // Verify the directive parsed correctly. let tune = try #require(score.tunes.first) - let directiveValue = tune.directives.compactMap { scope -> Bool? in - if case .justifyLast(let v) = scope.directive { return v } + let directiveValue = tune.directives.compactMap { scope -> Double? in + if case .stretchLast(let v) = scope.directive { return v } return nil }.last - let justifyLast = try #require(directiveValue as Bool?, - "%%ceolkit:justifylast true should produce a .justifyLast(true) directive on the tune") + let stretchLast = try #require(directiveValue, + "%%stretchlast 1 should produce a .stretchLast(1) directive on the tune") let (systems, usableWidth, laterHeaderW) = try justifiedSystems( - score: score, config: config, metadata: metadata, justifyLast: justifyLast) + score: score, config: config, metadata: metadata, stretchLast: stretchLast) let lastSystem = try #require(systems.last) let lastTotalW = lastSystem.measures.reduce(0.0) { $0 + $1.finalWidth } let targetWidth = usableWidth - laterHeaderW #expect(abs(lastTotalW - targetWidth) < 1.0, - "last system total width \(lastTotalW) should equal target \(targetWidth) when justifyLast is true") + "last system total width \(lastTotalW) should equal target \(targetWidth) when %%stretchlast is 1") } // MARK: Title placement @@ -666,22 +666,39 @@ struct StyleTests { "$d placeholder should be expanded using %%dateformat") } - // Without %%ceolkit:justifylast (or with false) the last system stays at its - // natural width, noticeably shorter than the full usable line width. - @Test func lastSystemIsRaggedRightByDefault() throws { + // With %%stretchlast 0 the last system stays at its natural width, noticeably shorter + // than the full usable line width. + @Test func lastSystemIsRaggedRightWithStretchLastZero() throws { let metadata = try BravuraMetadata.load() let config = SVGRenderConfig(pageSize: .letter.landscape) - // abcTune has no %%ceolkit:justifylast directive; default is false. let score = parse(abcTune).score let (systems, usableWidth, laterHeaderW) = try justifiedSystems( - score: score, config: config, metadata: metadata, justifyLast: false) + score: score, config: config, metadata: metadata, stretchLast: 0) let lastSystem = try #require(systems.last) let lastTotalW = lastSystem.measures.reduce(0.0) { $0 + $1.finalWidth } let targetWidth = usableWidth - laterHeaderW #expect(lastTotalW < targetWidth - 1.0, - "last system total width \(lastTotalW) should be less than target \(targetWidth) by default") + "last system total width \(lastTotalW) should be less than target \(targetWidth)") + } + + // abcTune's last line is four full bars, like every line above it, so the default + // %%stretchlast (0.25, abcm2ps's) stretches it to the line as well (issue #198). + @Test func fullLastSystemIsStretchedByDefault() throws { + let metadata = try BravuraMetadata.load() + let config = SVGRenderConfig(pageSize: .letter.landscape) + let score = parse(abcTune).score + + let (systems, usableWidth, laterHeaderW) = try justifiedSystems( + score: score, config: config, metadata: metadata, stretchLast: config.stretchLast) + + let lastSystem = try #require(systems.last) + let lastTotalW = lastSystem.measures.reduce(0.0) { $0 + $1.finalWidth } + let targetWidth = usableWidth - laterHeaderW + + #expect(abs(lastTotalW - targetWidth) < 1.0, + "last system total width \(lastTotalW) should equal target \(targetWidth)") } } diff --git a/Tests/CeolKitSVGRendererTests/TunebookTests.swift b/Tests/CeolKitSVGRendererTests/TunebookTests.swift index e2f5d22..d7e6ad5 100644 --- a/Tests/CeolKitSVGRendererTests/TunebookTests.swift +++ b/Tests/CeolKitSVGRendererTests/TunebookTests.swift @@ -30,7 +30,7 @@ struct TunebookTests { let abc = """ %abc-2.2 %%ceolkit:pipeformat true - %%ceolkit:justifylast true + %%stretchlast 1 %%landscape 1 X:1 T:Kalabakan (Borneo) diff --git a/Tests/CeolKitSVGRendererTests/VoiceLabelTests.swift b/Tests/CeolKitSVGRendererTests/VoiceLabelTests.swift index 13e9132..f917e56 100644 --- a/Tests/CeolKitSVGRendererTests/VoiceLabelTests.swift +++ b/Tests/CeolKitSVGRendererTests/VoiceLabelTests.swift @@ -169,7 +169,7 @@ struct VoiceLabelTests { @Test("The music still ends on the right margin: the gutter came out of the line") func gutterIsTakenOutOfTheLine() { var config = config - config.justifyLastSystem = true + config.stretchLast = 1 let rightMargin = config.pageSize.width - config.margins.right for abc in [twoVoices("", ""), twoVoices(named, alto)] { let staves = render(abc, config: config).staves diff --git a/Tests/CeolKitSVGRendererTests/__Snapshots__/CanzonettaConformanceTests/pageMatchesSnapshot.1.txt b/Tests/CeolKitSVGRendererTests/__Snapshots__/CanzonettaConformanceTests/pageMatchesSnapshot.1.txt index af23f23..611c1c2 100644 --- a/Tests/CeolKitSVGRendererTests/__Snapshots__/CanzonettaConformanceTests/pageMatchesSnapshot.1.txt +++ b/Tests/CeolKitSVGRendererTests/__Snapshots__/CanzonettaConformanceTests/pageMatchesSnapshot.1.txt @@ -870,311 +870,311 @@ - - - - - + + + + + - - - - - - - + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests/CeolKitSVGRendererTests/__Snapshots__/ZochartiLochConformanceTests/zochartiPageMatchesSnapshot.1.txt b/Tests/CeolKitSVGRendererTests/__Snapshots__/ZochartiLochConformanceTests/zochartiPageMatchesSnapshot.1.txt index f8632cd..b72d567 100644 --- a/Tests/CeolKitSVGRendererTests/__Snapshots__/ZochartiLochConformanceTests/zochartiPageMatchesSnapshot.1.txt +++ b/Tests/CeolKitSVGRendererTests/__Snapshots__/ZochartiLochConformanceTests/zochartiPageMatchesSnapshot.1.txt @@ -215,11 +215,11 @@ - - - - - + + + + + @@ -227,121 +227,121 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests/CeolKitSVGRendererTests/tunebook.abc b/Tests/CeolKitSVGRendererTests/tunebook.abc index eb51720..48f883f 100644 --- a/Tests/CeolKitSVGRendererTests/tunebook.abc +++ b/Tests/CeolKitSVGRendererTests/tunebook.abc @@ -7,7 +7,7 @@ %%graceslurs false %%dateformat "%e %B %Y %H:%M" %%ceolkit:pipeformat true -%%ceolkit:justifylast true +%%stretchlast 1 %%footer "Page $P\tSVPB G4 Medley 2026\tGenerated: $D" %%landscape 1 X:1