Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8c585c7
AI Image Editor: whole-book image enumeration + book-wide replacement
hatton May 30, 2026
3dd5687
Document AiImageEditorApi and remove the vestigial C# frame-message path
hatton May 31, 2026
ddf7be0
Merge remote-tracking branch 'origin/master' into AddAIImages
hatton Jun 19, 2026
da81d80
AI Image Editor: per-user OpenRouter key storage + serve editor from …
hatton Jun 19, 2026
3046752
go.sh --with: develop local library checkouts alongside Bloom
hatton Jun 19, 2026
1407bb6
go/dev: stage jQuery global in dev; make --with iframe-app startup wa…
hatton Jun 20, 2026
4be4735
AI Image Editor: document public RegisterWithApiHandler (preflight)
hatton Jul 3, 2026
87f4b36
Merge origin/master into AddAIImages (D5)
hatton Jul 9, 2026
c22c67f
AI Image Editor: apply preflight review decisions (D2-D4, F2-F5)
hatton Jul 9, 2026
118aa43
AI Image Editor: robustness, image-replacement matching, and dev-laun…
hatton Jul 10, 2026
3682c87
Merge remote-tracking branch 'origin/master' into AddAIImages
hatton Jul 10, 2026
87637f8
AI Image Editor: clean up orphaned ai-image files; localize menu item…
hatton Jul 10, 2026
5b5c881
AI Image Editor: fix CORS preflight, document OAuth reflection, test …
hatton Jul 10, 2026
0ad16c6
AI Image Editor: add host-side tests; extract testable helpers (BL-16…
hatton Jul 15, 2026
aa5fb65
AI Image Editor: restrict to editable image formats (BL-16523)
hatton Jul 15, 2026
3b59570
Merge remote-tracking branch 'origin/master' into AddAIImages
hatton Jul 15, 2026
a96190e
AI Image Editor: address review feedback (BL-16523)
hatton Jul 16, 2026
5e4e97b
AI Image Editor: remove the OpenRouter OAuth sign-in path (BL-16523)
hatton Jul 16, 2026
162ad6d
Merge remote-tracking branch 'origin/master' into AddAIImages
hatton Jul 17, 2026
dbecb8f
Correct two AI Image Editor code comments per review (BL-16523)
hatton Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions DistFiles/localization/en/BloomMediumPriority.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@
<note>ID: CollectionSettingsDialog.AdvancedTab.Experimental.AppBuilder</note>
<note>This checkbox enables the experimental publish-to-apps workflow.</note>
</trans-unit>
<trans-unit id="CollectionSettingsDialog.AdvancedTab.Experimental.EditWithAI" translate="no">
<source xml:lang="en">Edit Images with AI</source>
<note>ID: CollectionSettingsDialog.AdvancedTab.Experimental.EditWithAI</note>
<note>This checkbox enables the experimental "Edit with AI" image editor.</note>
</trans-unit>
<trans-unit id="Feature.AiImageEditing" translate="no">
<source xml:lang="en">Edit Images with AI</source>
<note>ID: Feature.AiImageEditing</note>
<note>Name of the AI image editing feature, used in subscription messages.</note>
</trans-unit>
<trans-unit id="EditTab.Image.EditWithAI">
Comment thread
hatton marked this conversation as resolved.
<source xml:lang="en">Edit with AI...</source>
<note>ID: EditTab.Image.EditWithAI</note>
<note>Context-menu item on an image in the Edit tab; opens the AI image editor in an overlay. The "..." indicates further UI will open, as in the sibling item "Choose image from your computer...".</note>
</trans-unit>
<!-- Drag Activity Tool -->
<trans-unit id="EditTab.Toolbox.DragActivity.ChooseSound">
<source xml:lang="en">Choose...</source>
Expand Down
44 changes: 37 additions & 7 deletions ReadMe.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Books for every child, in every language

Bloom is an [award winning](http://allchildrenreading.org/sil-international-wins-enabling-writers-prize-for-software-solution-to-childrens-book-shortage/) software solution to the children's book shortage among most of the world's languages. It is an application for Windows and [Linux](https://bloomlibrary.org/page/create/linux) that dramatically "lowers the bar" for creating, translating, and sharing books. With Bloom, communities can do the work for themselves instead of depending on outsiders. For more information, see https://bloomlibrary.org/about. For user documentation, see https://docs.bloomlibrary.org.
Bloom is an [award winning](http://allchildrenreading.org/sil-international-wins-enabling-writers-prize-for-software-solution-to-childrens-book-shortage/) software solution to the children's book shortage among most of the world's languages. It is an application for Windows and [Linux](https://bloomlibrary.org/page/create/linux) that dramatically "lowers the bar" for creating, translating, and sharing books. With Bloom, communities can do the work for themselves instead of depending on outsiders. For more information, see [https://bloomlibrary.org/about](https://bloomlibrary.org/about). For user documentation, see [https://docs.bloomlibrary.org](https://docs.bloomlibrary.org).

Internally, Bloom is a hybrid. It started as a C#/WinForms app with an embedded browser for editing documents and an embedded Adobe Acrobat for displaying PDF outputs. It is growing up to be a pure React-driven offline-capable web app, with a C# backend. In its current state, Bloom is hybrid of C#/web app in which the bits of the UI are gradually moving to html.

Expand All @@ -15,10 +15,9 @@ Internally, Bloom is a hybrid. It started as a C#/WinForms app with an embedded
```

3. Run a hot-reloading server for the front end and a "watch" run of the back end.
```bash
./go.sh
```

```bash
./go.sh
```

# Developing

Expand All @@ -29,11 +28,17 @@ We don't want developer and tester runs (and crashes) polluting our statistics.
### Set up formatting

In a terminal, run `dotnet tool restore`. This will install any tools we have put in .config/dotnet-tools.json along with the correct versions.

In Visual Studio, under Extensions, install "CSharpier". The extension's version will not be the same as the CSharpier installed by 'dotnet tool restore', but that's not a problem.

In Tools/Options, under CSharpier:General, set `Reformat with CSharpier on Save` to `true`. Note that you should set it for this solution, not globally.

You should also install the CSharpier extension in VSCode.

CSharpier should be using the version specified in `.config/dotnet-tools.json`.

When upgrading to a new version of CSharpier, to format everything, run `dotnet csharpier format src/BloomExe src/BloomTests src/WebView2PdfMaker`.

To hide reformatting-only commits from git blame, add the sha of the commit to `.git-blame-ignore-revs`.

For Typescript formatting, we use the Prettier extension in VSCode.
Expand All @@ -50,9 +55,32 @@ It may be helpful before submitting a PR to turn off pnpm dev and run pnpm build

If you have the team's agent skills installed (see the Skills section of AGENTS.md), the `/preflight` skill automates the rest of the pre-review checklist: typecheck, lint, tests, draft PR, and the bot review gauntlet.

### Developing a library alongside Bloom

Bloom uses several libraries that we maintain in their own repositories (they behave like a loose monorepo). By default, `./go.sh` uses each library exactly as installed in `node_modules` (i.e. as last `yarn install`ed / published) — you don't need their source.

When you're also working on one of those libraries, link your local checkout of it with the repeatable `--with` flag so your edits flow into the running Bloom:

```bash
./go.sh --with LIBRARY # auto-discover a sibling checkout
./go.sh --with LIBRARY=PATH # or point at an explicit checkout path
./go.sh --with bloom-player --with @sillsdev/config-r # link more than one
```

`LIBRARY` is one of the package names listed below; `PATH` is a checkout directory. With no path, the checkout is auto-discovered as a sibling of the Bloom repo (e.g. `../bloom-player`, or `../../bloom-player` from a git worktree). `go.sh` starts and stops the library's dev processes for you and tears them down on Ctrl-C; the library's own dependencies must already be installed in its checkout (`pnpm install` or `yarn install` there, as appropriate).

The linkable libraries (by package name) and what `--with` does for each:

- `bloom-ai-image-tools` — `--with` runs the library's own Vite dev server (`pnpm dev`) and points Bloom at it, giving full HMR inside the editor.
- `bloom-player` — `--with` aliases the package to your checkout and runs its watch-builds (both the imported library output and the standalone player assets), so your changes appear in Bloom's UI on reload.
- `@sillsdev/config-r` — `--with` aliases it to your checkout and runs its watch-build (`yarn build:dev`).

The list of linkable libraries lives in `src/BloomBrowserUI/scripts/devLibraries.mjs`; add an entry there to make another library linkable.

### Windows Defender exclusions

For performance reasons, you probably want to exclude at least the following in the Windows Defender settings:

- node.exe (process)
- Bloom source code folder (e.g. `C:/dev/BloomDesktop`)

Expand All @@ -79,6 +107,7 @@ Each time code is checked in, an automatic build begins on our [TeamCity build s
### Linux Development

5.4 is the last version for Linux until we get rid of WinForms.

See the `Version5.4` branch and ReadMe if you need to update it.

See [this page](https://bloomlibrary.org/page/create/linux) for how to run it.
Expand All @@ -89,9 +118,10 @@ UI localization happens on [Crowdin](https://crowdin.com/project/sil-bloom).

### Registry settings

One responsibility of Bloom desktop is to handle url's starting with "bloom://"", such as those used on bloomlibrary.org when the user clicks "Translate into _your_ language!" Making this work requires some registry settings. These are automatically created when you run Bloom. If you have multiple versions installed, just make sure that the one you ran most recently is the one you want to do the download.
One responsibility of Bloom desktop is to handle url's starting with "bloom://"", such as those used on bloomlibrary.org when the user clicks "Translate into *your* language!" Making this work requires some registry settings. These are automatically created when you run Bloom. If you have multiple versions installed, just make sure that the one you ran most recently is the one you want to do the download.

# License

Bloom is open source, using the [MIT License](http://sil.mit-license.org). It is Copyright SIL Global.
"Bloom" is a registered trademark of SIL Global.

"Bloom" is a registered trademark of SIL Global.
2 changes: 1 addition & 1 deletion src/BloomBrowserUI/bookEdit/js/bloomImages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,7 +1088,7 @@ function getFileLengthString(bytes): string {
}

// Gets the src attribute out of images, and the background-image:url() of everything else
function GetRawImageUrl(imgOrDivWithBackgroundImage): string {
export function GetRawImageUrl(imgOrDivWithBackgroundImage): string {
if ($(imgOrDivWithBackgroundImage).hasAttr("src")) {
return $(imgOrDivWithBackgroundImage).attr("src");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { getCanvasElementManager } from "../../toolbox/canvas/canvasElementPageB
import { kBackgroundImageClass } from "../../toolbox/canvas/canvasElementConstants";
import { BloomTooltip } from "../../../react_components/BloomToolTip";
import { useL10n } from "../../../react_components/l10nHooks";
import { useGetFeatureStatus } from "../../../react_components/featureStatus";
import { kBloomDisabledOpacity } from "../../../utils/colorUtils";
import { getAsync, useApiObject } from "../../../utils/bloomApi";
import AudioRecording from "../../toolbox/talkingBook/audioRecording";
Expand Down Expand Up @@ -88,6 +89,10 @@ const CanvasElementContextControls: React.FunctionComponent<{
};

const menuEl = useRef<HTMLElement | null>(null);
// The "Edit with AI" command is an experimental, subscription-gated feature.
// Its FeatureStatus.visible reflects whether the experimental feature is on;
// we feed that into the control context so the menu item is hidden when off.
const aiImageEditingStatus = useGetFeatureStatus("AiImageEditing");
const languageNameValues = useApiObject<ILanguageNameValues>(
"settings/languageNames",
{
Expand Down Expand Up @@ -454,6 +459,7 @@ const CanvasElementContextControls: React.FunctionComponent<{
textHasAudio,
hasClipboardText,
languageNameValues,
aiImageEditingAvailable: aiImageEditingStatus?.visible ?? false,
};

const definition =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, test } from "vitest";

import { matchReplacementsToElements } from "./aiEditorSlotMatching";

// Unit tests for the current-page slot matcher used by the AI editor's commit (see
// aiEditorSlotMatching.ts and canvasControlRegistry.ts editWithAi). The tricky case is a page
// with several image slots that share a filename: distinct replacements must land on distinct
// elements, in slot (ordinal) order, not all collapse onto the first same-filename element.

// A minimal stand-in for a replacement and a live page element, so the test needs no DOM.
interface Repl {
incomingId: string; // "{pageId}:{ordinal}"
oldSrc: string; // filename the replacement wants to land on
newSrc: string; // the replacement image (only used to identify the pair here)
}
interface El {
filename: string; // filename the live element currently shows
tag: string; // just a label so assertions can name the element
}

const ordinalOf = (r: Repl) =>
parseInt(r.incomingId.split(":").pop() ?? "", 10) || 0;

// Run the matcher with the Repl/El accessors wired up the way the real caller does.
function match(replacements: Repl[], candidates: El[]) {
return matchReplacementsToElements(
replacements,
ordinalOf,
(r) => r.oldSrc,
candidates,
(e) => e.filename,
);
}

describe("matchReplacementsToElements", () => {
test("matches a single replacement to the element with the same filename", () => {
const els: El[] = [
{ filename: "a.png", tag: "A" },
{ filename: "b.png", tag: "B" },
];
const result = match(
[{ incomingId: "p:1", oldSrc: "b.png", newSrc: "new-b.png" }],
els,
);

expect(result).toHaveLength(1);
expect(result[0].element.tag).toBe("B");
expect(result[0].replacement.newSrc).toBe("new-b.png");
});

test("two same-filename slots get distinct elements, paired by ordinal order", () => {
// Two placeholder slots that both currently show placeholder.png. The ordinal-0
// replacement must take the first placeholder element and ordinal-1 the second —
// neither collapsing onto the same element.
const els: El[] = [
{ filename: "placeholder.png", tag: "first" },
{ filename: "placeholder.png", tag: "second" },
];
// Deliberately pass them out of ordinal order to prove the matcher sorts.
const result = match(
[
{
incomingId: "p:1",
oldSrc: "placeholder.png",
newSrc: "gen-1.png",
},
{
incomingId: "p:0",
oldSrc: "placeholder.png",
newSrc: "gen-0.png",
},
],
els,
);

expect(result).toHaveLength(2);
// Applied in ascending ordinal order: ordinal 0 first, ordinal 1 second.
expect(result[0].replacement.newSrc).toBe("gen-0.png");
expect(result[0].element.tag).toBe("first");
expect(result[1].replacement.newSrc).toBe("gen-1.png");
expect(result[1].element.tag).toBe("second");
// Sanity: the two replacements did NOT land on the same element.
expect(result[0].element).not.toBe(result[1].element);
});

test("a replacement with no filename match is skipped, others still match", () => {
const els: El[] = [{ filename: "a.png", tag: "A" }];
const result = match(
[
{
incomingId: "p:0",
oldSrc: "missing.png",
newSrc: "gen-miss.png",
},
{ incomingId: "p:1", oldSrc: "a.png", newSrc: "gen-a.png" },
],
els,
);

expect(result).toHaveLength(1);
expect(result[0].replacement.newSrc).toBe("gen-a.png");
expect(result[0].element.tag).toBe("A");
});

test("more same-filename replacements than elements: extras drop out, no reuse", () => {
const els: El[] = [{ filename: "dup.png", tag: "only" }];
const result = match(
[
{ incomingId: "p:0", oldSrc: "dup.png", newSrc: "gen-0.png" },
{ incomingId: "p:1", oldSrc: "dup.png", newSrc: "gen-1.png" },
],
els,
);

// Only one element exists, so only the first (ordinal 0) replacement lands; the
// second finds no unused same-filename element and is omitted rather than reusing.
expect(result).toHaveLength(1);
expect(result[0].replacement.newSrc).toBe("gen-0.png");
});

test("does not mutate the caller's replacements array order", () => {
const replacements: Repl[] = [
{ incomingId: "p:2", oldSrc: "a.png", newSrc: "n2.png" },
{ incomingId: "p:0", oldSrc: "a.png", newSrc: "n0.png" },
];
const els: El[] = [
{ filename: "a.png", tag: "A0" },
{ filename: "a.png", tag: "A2" },
];
match(replacements, els);

// The matcher sorts a copy; the original array keeps its input order.
expect(replacements[0].incomingId).toBe("p:2");
expect(replacements[1].incomingId).toBe("p:0");
});
});
60 changes: 60 additions & 0 deletions src/BloomBrowserUI/bookEdit/toolbox/canvas/aiEditorSlotMatching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Pure slot-matching helper for applying AI-editor replacements to the currently-open page.
//
// When the AI commit returns replacements for the page the user is looking at, the front-end
// has to pair each replacement with the right live image element. Matching is by filename
// (a cache-busting query string or path prefix on the live element would defeat a full-src
// compare), but a single page can have several image slots that share the same filename —
// e.g. two empty placeholders, or the same photo used twice. Filename alone can't tell those
// apart, so we (a) apply in slot order — the ordinal in each replacement's "{pageId}:{n}" id
// counts the saved page's image holders in document order, which is the live candidates'
// order too — and (b) consume each element at most once, so distinct replacements land on
// distinct elements instead of all collapsing onto the first same-filename match.
//
// This is factored out of canvasControlRegistry.ts's editWithAi command so the pairing logic
// can be unit-tested without a DOM or the changeImage side effects; the caller supplies the
// ordinal/filename accessors and performs the actual image swap on the returned pairs.

export interface IReplacementMatch<TReplacement, TElement> {
replacement: TReplacement;
element: TElement;
}

/**
* Pairs replacements to candidate elements by filename, in ascending ordinal order, using each
* candidate at most once. A replacement with no filename match (given the still-unused
* candidates) is skipped and simply omitted from the result.
*
* @param replacements the current-page replacements to place
* @param ordinalOf extracts a replacement's slot ordinal (used only to order placement)
* @param wantedFilenameOf the filename a replacement wants to land on (from its oldSrc)
* @param candidates the live page's image-bearing elements, in document order
* @param candidateFilenameOf the filename currently shown by a candidate element
* @returns one {replacement, element} pair per successfully matched replacement, in the order
* they were applied (ascending ordinal)
*/
export function matchReplacementsToElements<TReplacement, TElement>(
replacements: TReplacement[],
ordinalOf: (replacement: TReplacement) => number,
wantedFilenameOf: (replacement: TReplacement) => string,
candidates: TElement[],
candidateFilenameOf: (element: TElement) => string,
): Array<IReplacementMatch<TReplacement, TElement>> {
const used = new Set<TElement>();
const matches: Array<IReplacementMatch<TReplacement, TElement>> = [];
[...replacements]
Comment thread
hatton marked this conversation as resolved.
.sort((a, b) => ordinalOf(a) - ordinalOf(b))
.forEach((replacement) => {
const wanted = wantedFilenameOf(replacement);
const element = candidates.find(
(candidate) =>
!used.has(candidate) &&
candidateFilenameOf(candidate) === wanted,
);
if (element === undefined) {
return;
}
used.add(element);
matches.push({ replacement, element });
});
return matches;
}
Loading