From cdae93192a3f409c41d382e91e83a540d89fe161 Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 3 Apr 2026 17:14:11 -0500 Subject: [PATCH] Derivative preserves custom pages (BL-16074) Also better handles various aspects of restoring a saved custom page state (e.g., disabled because no subscription) when the languages have changed since the custom page was saved. --- src/BloomExe/Book/Book.cs | 44 +++++++++++--------- src/BloomExe/Book/BookStarter.cs | 27 ++++++++++-- src/BloomExe/Book/TranslationGroupManager.cs | 34 +++++++++------ src/BloomExe/Book/XMatterHelper.cs | 34 +++++++++++++++ 4 files changed, 102 insertions(+), 37 deletions(-) diff --git a/src/BloomExe/Book/Book.cs b/src/BloomExe/Book/Book.cs index cccb5afed776..f9eda444e7c3 100644 --- a/src/BloomExe/Book/Book.cs +++ b/src/BloomExe/Book/Book.cs @@ -2040,6 +2040,28 @@ private void EnsureUpToDateMemoryUnprotected(IProgress progress) ); _bookData.MergeBrandingSettings(CollectionSettings.Subscription.BrandingKey); _bookData.SynchronizeDataItemsThroughoutDOM(); + // After SynchronizeDataItemsThroughoutDOM restores the content of custom layout pages + // from the data-div, the bloom-editables may reflect old language settings (e.g. when + // making a derivative or changing languages). Re-prepare those pages so they have + // bloom-editables for the current languages and the correct content-order classes. + foreach ( + var customPage in OurHtmlDom + .SafeSelectNodes( + "//div[contains(@class,'bloom-page') and contains(@class,'bloom-customLayout')]" + ) + .Cast() + ) + { + TranslationGroupManager.PrepareElementsInPageOrDocument(customPage, _bookData); + TranslationGroupManager.UpdateContentLanguageClasses( + customPage, + _bookData, + BookInfo.AppearanceSettings, + Language1Tag, + Language2Tag, + Language3Tag + ); + } licenseMetadata = GetLicenseMetadata(); // I think we should only mess with tags if we are updating the book for real. var oldTagsPath = Path.Combine(Storage.FolderPath, "tags.txt"); @@ -2541,14 +2563,7 @@ public void BringXmatterHtmlUpToDate(HtmlDom bookDOM) // Various things, especially publication, don't work with unknown page sizes. Layout layout = Layout.FromDomAndChoices(bookDOM, Layout.A5Portrait, fileLocator); var oldIds = new List(); - var customLayoutIds = bookDOM - .SafeSelectNodes( - "//div[contains(@class, 'bloom-page') and @data-custom-layout-id and contains(concat(' ', normalize-space(@class), ' '), ' bloom-customLayout ')]" - ) - .Cast() - .Select(page => page.GetAttribute("data-custom-layout-id")) - .Where(id => !string.IsNullOrEmpty(id)) - .ToHashSet(); + var customLayoutIds = XMatterHelper.GatherCustomLayoutIds(bookDOM); XMatterHelper.RemoveExistingXMatter(bookDOM, oldIds); // this says, if you can't figure out the page size, use the one we got before we removed the xmatter... // still requiring it to be a valid layout. @@ -2560,18 +2575,7 @@ public void BringXmatterHtmlUpToDate(HtmlDom bookDOM) _bookData.MetadataLanguage1Tag, oldIds ); - foreach ( - var page in bookDOM - .SafeSelectNodes( - "//div[contains(@class, 'bloom-page') and @data-custom-layout-id]" - ) - .Cast() - ) - { - var customLayoutId = page.GetAttribute("data-custom-layout-id"); - if (customLayoutIds.Contains(customLayoutId)) - page.AddClass("bloom-customLayout"); - } + XMatterHelper.RestoreCustomLayoutClasses(bookDOM, customLayoutIds); var dataBookLangs = bookDOM.GatherDataBookLanguages(); TranslationGroupManager.PrepareDataBookTranslationGroups(bookDOM.RawDom, dataBookLangs); diff --git a/src/BloomExe/Book/BookStarter.cs b/src/BloomExe/Book/BookStarter.cs index c01f2118489f..3902026d58dc 100644 --- a/src/BloomExe/Book/BookStarter.cs +++ b/src/BloomExe/Book/BookStarter.cs @@ -71,7 +71,7 @@ string parentCollectionPath var newBookFolder = Path.Combine(parentCollectionPath, initialBookName); CopyFolder(sourceBookFolder, newBookFolder); BookStorage.RemoveLocalOnlyFiles(newBookFolder); - //if something bad happens from here on out, we need to delete that folder we just made + //if something bad happens from here on out, we need to delete that folder we just made try { var oldNamedFile = Path.Combine( @@ -93,7 +93,11 @@ string parentCollectionPath RobustFile.Move(oldNamedFile, newNamedFile); //the destination may change here... - newBookFolder = SetupNewDocumentContents(sourceBookFolder, newBookFolder, newBookInstanceId); + newBookFolder = SetupNewDocumentContents( + sourceBookFolder, + newBookFolder, + newBookInstanceId + ); if (OnNextRunSimulateFailureMakingBook) throw new ApplicationException("Simulated failure for unit test"); @@ -166,7 +170,11 @@ private string GetMetaValue(SafeXmlDocument Dom, string name, string defaultValu return defaultValue; } - private string SetupNewDocumentContents(string sourceFolderPath, string initialPath, string newBookInstanceId) + private string SetupNewDocumentContents( + string sourceFolderPath, + string initialPath, + string newBookInstanceId + ) { // This bookInfo is temporary, just used to make the (also temporary) BookStorage we // use here in this method. I don't think it actually matters what its save context is. @@ -243,6 +251,10 @@ private string SetupNewDocumentContents(string sourceFolderPath, string initialP // class to figure out which BookData keys to remove. ClearUnneededOriginalContentFromDerivative(storage.Dom, bookData); + // Preserve the bloom-customLayout class through xmatter replacement, so that + // EnsureUpToDate can later decide whether to keep or remove it based on the subscription. + var customLayoutIds = XMatterHelper.GatherCustomLayoutIds(storage.Dom); + // For a new book, we discard any old xmatter Ids, so the new book will have its own page IDs. // One way this is helpful is caching cover images by page ID, so each book has a different cover page ID. XMatterHelper.RemoveExistingXMatter(storage.Dom, new List()); @@ -273,6 +285,9 @@ private string SetupNewDocumentContents(string sourceFolderPath, string initialP InjectXMatter(initialPath, storage, sizeAndOrientation); + // Restore bloom-customLayout to any pages that had it before xmatter replacement. + XMatterHelper.RestoreCustomLayoutClasses(storage.Dom, customLayoutIds); + SetLineageAndId(storage, sourceFolderPath, newBookInstanceId); if (makingTranslation) @@ -416,7 +431,11 @@ private static void ProcessXMatterMetaTags(BookStorage storage) storage.Dom.RemoveMetaElement("xmatter-for-children"); } - private void SetLineageAndId(BookStorage storage, string sourceFolderPath, string newBookInstanceId) + private void SetLineageAndId( + BookStorage storage, + string sourceFolderPath, + string newBookInstanceId + ) { string parentId = null; string lineage = null; diff --git a/src/BloomExe/Book/TranslationGroupManager.cs b/src/BloomExe/Book/TranslationGroupManager.cs index f4eda6406fdc..3a605510e8dd 100644 --- a/src/BloomExe/Book/TranslationGroupManager.cs +++ b/src/BloomExe/Book/TranslationGroupManager.cs @@ -485,23 +485,31 @@ private static void UpdateContentLanguageClassesOnElement( string[] dataDefaultLanguages ) { - HashSet classesToKeep = null; + HtmlDom.RemoveClassesBeginningWith(editable, "bloom-content"); if (editable.ParentWithClass("bloom-customLayout") != null) { - // on a custom page, every bloom-editable is the only thing visible - // in its translationGroup, and visibility is not controlled by the - // appearance system. So we will never add these classes. However, - // they might have been copied there when setting up the custom layout. - // To avoid a distracting and possibly annoying change of appearance - // when transitioning to a custom layout, we will keep whatever class - // from this group the element had when the conversion happened. - // It's also important not to remove them in the copy in the data-div; + // On a custom layout page, assign bloom-contentFirst/Second/Third based on the + // data-default-languages attribute of the parent group and whether the element + // is visible. This ensures correctness after language changes or derivative creation. + // It's also important not to remove these from the copy in the data-div; // we prevent that by renaming bloom-editable and bloom-translationGroup. - classesToKeep = new HashSet( - new[] { "bloom-contentFirst", "bloom-contentSecond", "bloom-contentThird" } - ); + if (IsVisible(editable)) + { + var group = editable.ParentNode as SafeXmlElement; + switch (group?.GetAttribute("data-default-languages")?.Trim()) + { + case "V": + editable.AddClass("bloom-contentFirst"); + break; + case "N1": + editable.AddClass("bloom-contentSecond"); + break; + case "N2": + editable.AddClass("bloom-contentThird"); + break; + } + } } - HtmlDom.RemoveClassesBeginningWith(editable, "bloom-content", classesToKeep); var lang = editable.GetAttribute("lang"); //These bloom-content* classes are used by some stylesheet rules, primarily to boost the font-size of some languages. diff --git a/src/BloomExe/Book/XMatterHelper.cs b/src/BloomExe/Book/XMatterHelper.cs index d07f159e69a1..d87e78d96862 100644 --- a/src/BloomExe/Book/XMatterHelper.cs +++ b/src/BloomExe/Book/XMatterHelper.cs @@ -536,6 +536,40 @@ SafeXmlElement div in dom.SafeSelectNodes( } } + /// + /// Collect the data-custom-layout-id values of any pages that currently have bloom-customLayout, + /// so that can re-apply the class after xmatter replacement. + /// + public static HashSet GatherCustomLayoutIds(HtmlDom dom) + { + return dom.SafeSelectNodes( + "//div[contains(@class, 'bloom-page') and @data-custom-layout-id and contains(concat(' ', normalize-space(@class), ' '), ' bloom-customLayout ')]" + ) + .Cast() + .Select(page => page.GetAttribute("data-custom-layout-id")) + .Where(id => !string.IsNullOrEmpty(id)) + .ToHashSet(); + } + + /// + /// Re-apply bloom-customLayout to any pages whose data-custom-layout-id was collected by + /// before xmatter was replaced. + /// + public static void RestoreCustomLayoutClasses(HtmlDom dom, HashSet customLayoutIds) + { + foreach ( + var page in dom.SafeSelectNodes( + "//div[contains(@class, 'bloom-page') and @data-custom-layout-id]" + ) + .Cast() + ) + { + var id = page.GetAttribute("data-custom-layout-id"); + if (customLayoutIds.Contains(id)) + page.AddClass("bloom-customLayout"); + } + } + /// /// This will return a different name if we recognize the submitted name and we know that we have changed it or retired it. ///