Line data Source code
1 : /*
2 : * SPDX-License-Identifier: GPL-3.0-only
3 : * MuseScore-Studio-CLA-applies
4 : *
5 : * MuseScore Studio
6 : * Music Composition & Notation
7 : *
8 : * Copyright (C) 2026 MuseScore Limited and others
9 : *
10 : * This program is free software: you can redistribute it and/or modify
11 : * it under the terms of the GNU General Public License version 3 as
12 : * published by the Free Software Foundation.
13 : *
14 : * This program is distributed in the hope that it will be useful,
15 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 : * GNU General Public License for more details.
18 : *
19 : * You should have received a copy of the GNU General Public License
20 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
21 : */
22 :
23 : #include "headerfooterlayout.h"
24 :
25 : #include "dom/factory.h"
26 : #include "dom/masterscore.h"
27 : #include "dom/mscore.h"
28 : #include "dom/page.h"
29 : #include "dom/text.h"
30 :
31 : #include "ifileinfoprovider.h"
32 : #include "tlayout.h"
33 :
34 : using namespace mu::engraving;
35 : using namespace mu::engraving::rendering::score;
36 :
37 4131 : void HeaderFooterLayout::layoutHeaderFooter(LayoutContext& ctx, Page* page)
38 : {
39 4131 : const bool isPageMode = ctx.conf().isMode(LayoutMode::PAGE) || ctx.conf().isMode(LayoutMode::FLOAT);
40 4131 : if (!isPageMode) {
41 0 : return;
42 : }
43 :
44 4131 : const int n = page->getDisplayPageNumber();
45 :
46 4131 : if (mustShowHeadersForPage(ctx, page)) {
47 147 : const bool odd = (n & 1) || !ctx.conf().styleB(Sid::headerOddEven);
48 147 : createUpdateHeaderText(ctx, page, 0, ctx.conf().styleSt(odd ? Sid::oddHeaderL : Sid::evenHeaderL));
49 147 : createUpdateHeaderText(ctx, page, 1, ctx.conf().styleSt(odd ? Sid::oddHeaderC : Sid::evenHeaderC));
50 147 : createUpdateHeaderText(ctx, page, 2, ctx.conf().styleSt(odd ? Sid::oddHeaderR : Sid::evenHeaderR));
51 : } else {
52 15936 : for (int area = 0; area < MAX_HEADERS; ++area) {
53 11952 : removeHeaderText(page, area);
54 : }
55 : }
56 :
57 4131 : if (mustShowFootersForPage(ctx, page)) {
58 4126 : const bool odd = (n & 1) || !ctx.conf().styleB(Sid::footerOddEven);
59 4126 : createUpdateFooterText(ctx, page, 0, ctx.conf().styleSt(odd ? Sid::oddFooterL : Sid::evenFooterL));
60 4126 : createUpdateFooterText(ctx, page, 1, ctx.conf().styleSt(odd ? Sid::oddFooterC : Sid::evenFooterC));
61 4126 : createUpdateFooterText(ctx, page, 2, ctx.conf().styleSt(odd ? Sid::oddFooterR : Sid::evenFooterR));
62 : } else {
63 20 : for (int area = 0; area < MAX_FOOTERS; ++area) {
64 15 : removeFooterText(page, area);
65 : }
66 : }
67 : }
68 :
69 441 : void HeaderFooterLayout::createUpdateHeaderText(LayoutContext& ctx, Page* page, int area, const String& s)
70 : {
71 441 : if (s.empty()) {
72 270 : removeHeaderText(page, area);
73 270 : return;
74 : }
75 :
76 171 : Text* text = page->headerText(area);
77 171 : if (!text) {
78 145 : text = Factory::createText(page, TextStyleType::HEADER);
79 145 : text->setFlag(ElementFlag::MOVABLE, false);
80 145 : text->setFlag(ElementFlag::GENERATED, true); // set to disable editing
81 145 : text->setLayoutToParentWidth(true);
82 145 : page->setHeaderText(area, text);
83 :
84 145 : switch (area) {
85 96 : case 0:
86 96 : text->setAlign({ AlignH::LEFT, AlignV::TOP });
87 96 : text->setPosition(AlignH::LEFT);
88 96 : break;
89 6 : case 1:
90 6 : text->setAlign({ AlignH::HCENTER, AlignV::TOP });
91 6 : text->setPosition(AlignH::HCENTER);
92 6 : break;
93 43 : case 2:
94 43 : text->setAlign({ AlignH::RIGHT, AlignV::TOP });
95 43 : text->setPosition(AlignH::RIGHT);
96 43 : break;
97 0 : default:
98 0 : break;
99 : }
100 : }
101 :
102 171 : if (!updateHeaderFooterText(ctx, page, text, s)) {
103 19 : removeHeaderText(page, area);
104 : }
105 : }
106 :
107 12378 : void HeaderFooterLayout::createUpdateFooterText(LayoutContext& ctx, Page* page, int area, const String& s)
108 : {
109 12378 : if (s.empty()) {
110 7261 : removeFooterText(page, area);
111 7261 : return;
112 : }
113 :
114 5117 : Text* text = page->footerText(area);
115 5117 : if (!text) {
116 5117 : text = Factory::createText(page, TextStyleType::FOOTER);
117 5117 : text->setFlag(ElementFlag::MOVABLE, false);
118 5117 : text->setFlag(ElementFlag::GENERATED, true); // set to disable editing
119 5117 : text->setLayoutToParentWidth(true);
120 5117 : page->setFooterText(area, text);
121 :
122 5117 : switch (area) {
123 27 : case 0:
124 27 : text->setAlign({ AlignH::LEFT, AlignV::BOTTOM });
125 27 : text->setPosition(AlignH::LEFT);
126 27 : break;
127 4126 : case 1:
128 4126 : text->setAlign({ AlignH::HCENTER, AlignV::BOTTOM });
129 4126 : text->setPosition(AlignH::HCENTER);
130 4126 : break;
131 964 : case 2:
132 964 : text->setAlign({ AlignH::RIGHT, AlignV::BOTTOM });
133 964 : text->setPosition(AlignH::RIGHT);
134 964 : break;
135 0 : default:
136 0 : break;
137 : }
138 : }
139 :
140 5117 : if (!updateHeaderFooterText(ctx, page, text, s)) {
141 5029 : removeFooterText(page, area);
142 : }
143 : }
144 :
145 5288 : bool HeaderFooterLayout::updateHeaderFooterText(LayoutContext& ctx, Page* page, Text* text, const String& s)
146 : {
147 : // Hack: we can't use toXmlEscaped on the entire string because this would erase any manual XML
148 : // formatting, but we do want to be able to use a plain '&' in favour of XML character entities ...
149 : // ... also, opening less-than characters are escaped if they are followed by a space or a digit,
150 : // to avoid confusion with XML tags, but not if they are followed by a letter
151 5288 : String escaped;
152 30589 : for (size_t i = 0, n = s.size(); i < n; ++i) {
153 25301 : const Char c = s.at(i);
154 25301 : if (c == '&') {
155 0 : escaped += u"&";
156 0 : continue;
157 25301 : } else if (c == '<' && (i + 1 < n) && (s.at(i + 1).isSpace() || s.at(i + 1).isDigit())) {
158 0 : escaped += u"<";
159 0 : continue;
160 : }
161 25301 : escaped += c;
162 : }
163 :
164 : // first formatting pass - apply TextStyleType formatting and any manual XML formatting
165 5288 : text->setXmlText(escaped);
166 5288 : text->createBlocks();
167 :
168 : // second formatting pass - replace macros and apply their unique formatting (if any)
169 5288 : int emptyBlocks = 0;
170 5288 : std::vector<TextBlock> newBlocks;
171 10612 : for (const TextBlock& oldBlock : text->ldata()->blocks) {
172 5324 : Text* dummyText = Factory::createText(ctx.dom().dummyParent(), text->textStyleType());
173 10648 : dummyText->mutldata()->blocks = { replaceTextMacros(ctx, page, oldBlock) };
174 5324 : dummyText->genText();
175 5324 : dummyText->createBlocks();
176 5324 : if (dummyText->xmlText().isEmpty()) {
177 5084 : emptyBlocks++; // count empty blocks to remove them later if there is only 1 empty block
178 : }
179 5324 : newBlocks.insert(newBlocks.end(), dummyText->ldata()->blocks.cbegin(), dummyText->ldata()->blocks.cend());
180 5324 : delete dummyText;
181 : }
182 :
183 5288 : if (newBlocks.empty() || (newBlocks.size() == 1 && emptyBlocks == 1)) {
184 : // If there is no block, or the only block is empty, tell the caller that the text should be removed.
185 : // This way, empty lines are preserved if and only if there also is some text anywhere in the header/footer.
186 5048 : return false;
187 : }
188 :
189 240 : text->mutldata()->blocks = newBlocks;
190 240 : TLayout::layoutText(text, text->mutldata());
191 :
192 240 : return true;
193 10612 : }
194 :
195 12241 : void HeaderFooterLayout::removeHeaderText(Page* page, int area)
196 : {
197 12241 : if (Text* t = page->headerText(area)) {
198 19 : page->setHeaderText(area, nullptr);
199 19 : delete t;
200 : }
201 12241 : }
202 :
203 12305 : void HeaderFooterLayout::removeFooterText(Page* page, int area)
204 : {
205 12305 : if (Text* t = page->footerText(area)) {
206 5029 : page->setFooterText(area, nullptr);
207 5029 : delete t;
208 : }
209 12305 : }
210 :
211 : //---------------------------------------------------------
212 : // replaceTextMacros
213 : // (keep in sync with toolTipHeaderFooter in EditStyle::EditStyle())
214 : // $p - page number, except on first page
215 : // $P - page number, on all pages
216 : // $N - page number, if there is more than one
217 : // $n - number of pages
218 : // $i - part name, except on first page
219 : // $I - part name, on all pages
220 : // $f - file name
221 : // $F - file path+name
222 : // $d - current date
223 : // $D - creation date
224 : // $m - last modification time
225 : // $M - last modification date
226 : // $C - copyright, on first page only
227 : // $c - copyright, on all pages
228 : // $v - MuseScore version the score was last saved with
229 : // $r - MuseScore revision the score was last saved with
230 : // $$ - the $ sign itself
231 : // $:tag: - any metadata tag
232 : //
233 : // tags always defined (see Score::init())):
234 : // copyright
235 : // creationDate
236 : // movementNumber
237 : // movementTitle
238 : // platform
239 : // source
240 : // workNumber
241 : // workTitle
242 : //---------------------------------------------------------
243 :
244 5324 : TextBlock HeaderFooterLayout::replaceTextMacros(LayoutContext& ctx, const Page* page, const TextBlock& tb)
245 : {
246 5324 : std::list<TextFragment> newFragments;
247 10648 : for (const TextFragment& tf: tb.fragments()) {
248 5324 : const CharFormat defaultFormat = tf.format;
249 5324 : const String& s = tf.text;
250 :
251 : // if this is the first fragment, or the current fragment has a different format, we need to start a new fragment
252 5324 : if (newFragments.size() == 0 || newFragments.back().format != defaultFormat) {
253 5324 : newFragments.emplace_back(TextFragment());
254 5324 : newFragments.back().format = defaultFormat;
255 : }
256 :
257 10898 : for (size_t i = 0, n = s.size(); i < n; ++i) {
258 5574 : Char c = s.at(i);
259 5574 : if (c == '$' && (i < (n - 1))) {
260 5286 : Char nc = s.at(i + 1);
261 5286 : switch (nc.toAscii()) {
262 1131 : case 'p': // not on first page of the project nor page number 1
263 1131 : if (!page->pageNumber() || page->getDisplayPageNumber() <= 1) {
264 970 : break;
265 : }
266 : [[fallthrough]];
267 : case 'N': // on page 1 only if there are multiple pages
268 177 : if ((static_cast<int>(page->score()->npages()) + page->score()->pageNumberOffset()) <= 1) {
269 6 : ctx.mutState().setMustRecomputeHeadersFooters(true);
270 6 : break;
271 : }
272 : [[fallthrough]];
273 : case 'P': // on all pages
274 : {
275 184 : const int no = page->getDisplayPageNumber();
276 184 : if (no > 0) {
277 184 : const String pageNumberString = String::number(no);
278 184 : const CharFormat pageNumberFormat = formatForMacro(ctx, String('$' + nc));
279 184 : appendFormattedString(newFragments, pageNumberString, defaultFormat, pageNumberFormat);
280 184 : }
281 : }
282 184 : break;
283 0 : case 'n':
284 : {
285 0 : ctx.mutState().setMustRecomputeHeadersFooters(true);
286 0 : const int no = static_cast<int>(page->score()->npages()) + page->score()->pageNumberOffset();
287 0 : const String numberOfPagesString = String::number(no);
288 0 : const CharFormat pageNumberFormat = formatForMacro(ctx, String('$' + nc));
289 0 : appendFormattedString(newFragments, numberOfPagesString, defaultFormat, pageNumberFormat);
290 0 : }
291 0 : break;
292 0 : case 'i': // not on first page
293 0 : if (!page->pageNumber()) {
294 0 : break;
295 : }
296 : [[fallthrough]];
297 : case 'I':
298 0 : newFragments.back().text += page->score()->metaTag(u"partName");
299 0 : break;
300 0 : case 'f':
301 0 : newFragments.back().text += page->score()->masterScore()->fileInfo()->fileName(false).toString();
302 0 : break;
303 0 : case 'F':
304 0 : newFragments.back().text += page->score()->masterScore()->fileInfo()->path().toString();
305 0 : break;
306 0 : case 'd':
307 0 : newFragments.back().text += muse::Date::currentDate().toString(muse::DateFormat::ISODate);
308 0 : break;
309 0 : case 'D':
310 : {
311 0 : String creationDate = page->score()->metaTag(u"creationDate");
312 0 : if (creationDate.isEmpty()) {
313 0 : newFragments.back().text += page->score()->masterScore()->fileInfo()->birthTime().date().toString(
314 0 : muse::DateFormat::ISODate);
315 : } else {
316 0 : newFragments.back().text += muse::Date::fromStringISOFormat(creationDate).toString(
317 0 : muse::DateFormat::ISODate);
318 : }
319 0 : }
320 0 : break;
321 0 : case 'm': {
322 0 : IFileInfoProviderPtr fileInfo = page->score()->masterScore()->fileInfo();
323 0 : if (fileInfo->isNewlyCreated()) {
324 0 : newFragments.back().text += String(u"HH:mm:ss");
325 : } else {
326 0 : newFragments.back().text += fileInfo->lastModified().time().toString(muse::DateFormat::ISODate);
327 : }
328 0 : }
329 0 : break;
330 0 : case 'M': {
331 0 : IFileInfoProviderPtr fileInfo = page->score()->masterScore()->fileInfo();
332 0 : if (fileInfo->isNewlyCreated()) {
333 0 : newFragments.back().text += String(u"YYYY-MM-DD");
334 : } else {
335 0 : newFragments.back().text += fileInfo->lastModified().date().toString(muse::DateFormat::ISODate);
336 : }
337 0 : }
338 0 : break;
339 3071 : case 'C': // only on first page
340 3071 : if (page->pageNumber()) {
341 130 : break;
342 : }
343 : [[fallthrough]];
344 : case 'c':
345 : {
346 2941 : const String copyrightString = page->score()->metaTag(u"copyright");
347 2941 : const CharFormat copyrightFormat = formatForMacro(ctx, String('$' + nc));
348 2941 : appendFormattedString(newFragments, copyrightString, defaultFormat, copyrightFormat);
349 2941 : }
350 2941 : break;
351 0 : case 'v':
352 0 : if (page->score()->dirty()) {
353 0 : newFragments.back().text += application()->version().toString();
354 : } else {
355 0 : newFragments.back().text += page->score()->mscoreVersion();
356 : }
357 0 : break;
358 0 : case 'r':
359 0 : if (page->score()->dirty()) {
360 0 : newFragments.back().text += application()->revision();
361 : } else {
362 0 : int rev = page->score()->mscoreRevision();
363 0 : if (rev > 0 && rev <= 5709) { // MuseScore 1.x and earlier used decimal numbers, referring to a commit on SourceForge.net, the largest being 5709
364 0 : newFragments.back().text += String::number(rev, 10);
365 0 : } else if (rev > 0xffffff) { // MuseScore 2.0 and later use a >= 7-digit hex SHA, referring to a commit on GitHub.com
366 0 : newFragments.back().text += String::number(rev, 16);
367 : } else { // unknown, like in a self-built development version, or in a very old version of MuseScore before the revision number was tracked
368 0 : newFragments.back().text += String::number(rev, 10); // or "" or "Unknown"?
369 : }
370 : }
371 0 : break;
372 0 : case '$':
373 0 : newFragments.back().text += '$';
374 0 : break;
375 1055 : case ':':
376 : {
377 1055 : String tag;
378 1055 : size_t k = i + 2;
379 10550 : for (; k < n; ++k) {
380 10550 : if (s.at(k) == u':') {
381 1055 : break;
382 : }
383 9495 : tag += s.at(k);
384 : }
385 1055 : if (k != n) { // found ':' ?
386 1055 : if (tag == u"copyright") {
387 1055 : const String copyrightString = page->score()->metaTag(tag);
388 1055 : const CharFormat copyrightFormat = formatForMacro(ctx, String(u"$:" + tag + u":"));
389 1055 : appendFormattedString(newFragments, copyrightString, defaultFormat, copyrightFormat);
390 1055 : } else {
391 0 : newFragments.back().text += page->score()->metaTag(tag);
392 : }
393 1055 : i = k - 1;
394 : }
395 1055 : }
396 1055 : break;
397 0 : default:
398 0 : newFragments.back().text += '$';
399 0 : newFragments.back().text += nc;
400 0 : break;
401 : }
402 5286 : ++i;
403 : } else {
404 288 : newFragments.back().text += c;
405 : }
406 : }
407 5324 : }
408 :
409 5324 : TextBlock newBlock;
410 5324 : newBlock.fragments() = newFragments;
411 5324 : return newBlock;
412 5324 : }
413 :
414 4180 : CharFormat HeaderFooterLayout::formatForMacro(const LayoutContext& ctx, const String& macro)
415 : {
416 4180 : CharFormat format;
417 4180 : if (macro == "$c" || macro == "$C" || macro == "$:copyright:") {
418 3996 : format.setStyle(ctx.conf().styleV(Sid::copyrightFontStyle).value<FontStyle>());
419 3996 : format.setFontSize(ctx.conf().styleD(Sid::copyrightFontSize));
420 3996 : format.setFontFamily(ctx.conf().styleSt(Sid::copyrightFontFace));
421 184 : } else if (macro == "$p" || macro == "$P" || macro == "$n" || macro == "$N") {
422 184 : format.setStyle(ctx.conf().styleV(Sid::pageNumberFontStyle).value<FontStyle>());
423 184 : format.setFontSize(ctx.conf().styleD(Sid::pageNumberFontSize));
424 184 : format.setFontFamily(ctx.conf().styleSt(Sid::pageNumberFontFace));
425 : }
426 4180 : return format;
427 0 : }
428 :
429 4180 : void HeaderFooterLayout::appendFormattedString(std::list<TextFragment>& fragments, const String& string, const CharFormat& defaultFormat,
430 : const CharFormat& newFormat)
431 : {
432 : // If the default format equals the format for this macro, we don't need to create a new fragment...
433 4180 : if (defaultFormat == newFormat) {
434 4027 : fragments.back().text += string;
435 4027 : return;
436 : }
437 153 : TextFragment newFragment(string);
438 153 : newFragment.format = newFormat;
439 153 : fragments.emplace_back(newFragment);
440 153 : fragments.emplace_back(TextFragment()); // Start next fragment
441 153 : fragments.back().format = defaultFormat; // reset to default for next fragment
442 153 : }
443 :
444 4125 : double HeaderFooterLayout::headerExtension(const LayoutContext& ctx, const Page* page)
445 : {
446 4125 : const bool isPageMode = ctx.conf().isMode(LayoutMode::PAGE) || ctx.conf().isMode(LayoutMode::FLOAT);
447 4125 : if (!isPageMode) {
448 0 : return 0.0;
449 : }
450 :
451 4125 : if (mustShowHeadersForPage(ctx, page)) {
452 141 : double maxHeight = 0.0;
453 564 : for (int area = 0; area < MAX_HEADERS; ++area) {
454 423 : if (Text* text = page->headerText(area)) {
455 140 : double h = text->height();
456 140 : if (h > maxHeight) {
457 136 : maxHeight = h;
458 : }
459 : }
460 : }
461 141 : const double offset = ctx.conf().styleV(Sid::headerOffset).value<PointF>().y() * DPMM;
462 141 : return std::max(0.0, maxHeight - offset);
463 : }
464 :
465 3984 : return 0.0;
466 : }
467 :
468 4125 : double HeaderFooterLayout::footerExtension(const LayoutContext& ctx, const Page* page)
469 : {
470 4125 : const bool isPageMode = ctx.conf().isMode(LayoutMode::PAGE) || ctx.conf().isMode(LayoutMode::FLOAT);
471 4125 : if (!isPageMode) {
472 0 : return 0.0;
473 : }
474 :
475 4125 : if (mustShowFootersForPage(ctx, page)) {
476 4120 : double maxHeight = 0.0;
477 16480 : for (int area = 0; area < MAX_FOOTERS; ++area) {
478 12360 : if (Text* text = page->footerText(area)) {
479 88 : double h = text->height();
480 88 : if (h > maxHeight) {
481 80 : maxHeight = h;
482 : }
483 : }
484 : }
485 4120 : const double offset = ctx.conf().styleV(Sid::footerOffset).value<PointF>().y() * DPMM;
486 4120 : return std::max(0.0, maxHeight - offset);
487 : }
488 5 : return 0.0;
489 : }
490 :
491 0 : bool HeaderFooterLayout::containsTimestampMacros(const String& text)
492 : {
493 0 : return text.contains(u"$d")
494 0 : || text.contains(u"$D")
495 0 : || text.contains(u"$m")
496 0 : || text.contains(u"$M");
497 : }
498 :
499 0 : bool HeaderFooterLayout::scoreHasTimestampHeadersFooters(const Score* score)
500 : {
501 0 : const MStyle& style = score->style();
502 :
503 0 : if (style.styleB(Sid::showHeader)) {
504 0 : if (containsTimestampMacros(style.styleSt(Sid::oddHeaderL))
505 0 : || containsTimestampMacros(style.styleSt(Sid::oddHeaderC))
506 0 : || containsTimestampMacros(style.styleSt(Sid::oddHeaderR))
507 0 : || containsTimestampMacros(style.styleSt(Sid::evenHeaderL))
508 0 : || containsTimestampMacros(style.styleSt(Sid::evenHeaderC))
509 0 : || containsTimestampMacros(style.styleSt(Sid::evenHeaderR))) {
510 0 : return true;
511 : }
512 : }
513 :
514 0 : if (style.styleB(Sid::showFooter)) {
515 0 : if (containsTimestampMacros(style.styleSt(Sid::oddFooterL))
516 0 : || containsTimestampMacros(style.styleSt(Sid::oddFooterC))
517 0 : || containsTimestampMacros(style.styleSt(Sid::oddFooterR))
518 0 : || containsTimestampMacros(style.styleSt(Sid::evenFooterL))
519 0 : || containsTimestampMacros(style.styleSt(Sid::evenFooterC))
520 0 : || containsTimestampMacros(style.styleSt(Sid::evenFooterR))) {
521 0 : return true;
522 : }
523 : }
524 :
525 0 : return false;
526 : }
527 :
528 8256 : bool HeaderFooterLayout::mustShowHeadersForPage(const LayoutContext& ctx, const Page* page)
529 : {
530 8256 : return ctx.conf().styleB(Sid::showHeader)
531 14308 : && ((page->pageNumber() && page->getDisplayPageNumber() != 1)
532 14308 : || ctx.conf().styleB(Sid::headerFirstPage));
533 : }
534 :
535 8256 : bool HeaderFooterLayout::mustShowFootersForPage(const LayoutContext& ctx, const Page* page)
536 : {
537 8256 : return ctx.conf().styleB(Sid::showFooter)
538 16178 : && ((page->pageNumber() && page->getDisplayPageNumber() != 1)
539 16178 : || ctx.conf().styleB(Sid::footerFirstPage));
540 : }
|