diff --git a/src/author/proposal.ts b/src/author/proposal.ts
index 16b48b0..5096ab0 100644
--- a/src/author/proposal.ts
+++ b/src/author/proposal.ts
@@ -120,12 +120,20 @@ function bindContent(node: AuthoringProposalNode, element: Element, attributes:
const apply = (attribute: string, value: JsonValue): void => {
const change = decision(attribute);
if (change?.action === 'omit') return;
- if (attributes[attribute] !== undefined && JSON.stringify(attributes[attribute]) !== JSON.stringify(value) && !change) throw new Error(`proposal ${node.id}.${attribute} changes source content without an explicit source decision`);
+ if (attributes[attribute] !== undefined && !sourceAttributeMatchesProposal(node.block, attribute, attributes[attribute], value) && !change) throw new Error(`proposal ${node.id}.${attribute} changes source content without an explicit source decision`);
attributes[attribute] = change?.action === 'replace' || change?.action === 'add' ? change.value! : value;
};
for (const [attribute, value] of sourceAttributes(element, node.block)) apply(attribute, value);
}
+/** Only native image dimensions accept their numeric proposal form; canonical plans retain source strings. */
+function sourceAttributeMatchesProposal(block: string, attribute: string, proposed: JsonValue, source: JsonValue): boolean {
+ if (JSON.stringify(proposed) === JSON.stringify(source)) return true;
+ return block === 'core/image' && (attribute === 'width' || attribute === 'height')
+ && typeof proposed === 'number' && Number.isSafeInteger(proposed) && proposed > 0
+ && source === String(proposed);
+}
+
function safeHtml(element: Element): string { const safe = richTextSafe(element); if (!safe.safe) throw new Error(`source content is not RichText-safe: ${safe.reason}`); return cleanRichText(element).html; }
function ownsContent(block: string): boolean { return ['core/image', 'core/button', 'core/heading', 'core/paragraph', 'core/list-item'].includes(block); }
function sourceLocation(element: Element | undefined, dom: JSDOM, path?: string): AuthoringCoverageLocation | undefined { const loc = element && dom.nodeLocation(element); return loc ? { path, htmlLine: loc.startLine, htmlColumn: loc.startCol, offset: loc.startOffset } : undefined; }
diff --git a/test/author.proposal.test.ts b/test/author.proposal.test.ts
index 77ec57f..bc18182 100644
--- a/test/author.proposal.test.ts
+++ b/test/author.proposal.test.ts
@@ -15,7 +15,7 @@ import {
writeGeneratedRegisteredBlock,
} from '../src/index.js';
import { validateSourceContent } from '../src/author/content.js';
-import type { AuthoringStructureNode } from '../src/authoring/schema.js';
+import type { AuthoringStructureNode, JsonValue } from '../src/authoring/schema.js';
const benchmarkRoot = path.resolve('benchmarks/authoring/sources');
async function source(relative: string): Promise<{ html: string; sourcePath: string }> {
@@ -136,6 +136,51 @@ describe('author proposal boundary', () => {
expect(report.package!.canonicalPlan!.sourceDecisions![0]).toMatchObject({ action: 'replace', original: 'Original copy', value: 'Revised copy' });
});
+ it.each([
+ ['direct image', '
', 'img'],
+ ['figure image', '
';
+ const reference = refs(html)('img');
+ const proposal = (width: JsonValue, height: JsonValue) => ({ structure: [{ id: 'image', block: 'core/image', sourceRef: reference, attributes: { width, height } }] });
+ const changed = await author(html, { author: { name: 'example/proposal' }, proposal: proposal(641, 480) });
+ expect(changed.ok).toBe(false);
+ expect(changed.items.map((item) => item.reason).join('\n')).toMatch(/changes source content without an explicit source decision/i);
+ for (const value of [640.5, 0, -640, Infinity, true, {}, Number.MAX_SAFE_INTEGER + 1, '640px']) {
+ const rejected = await author(html, { author: { name: 'example/proposal' }, proposal: proposal(value, 480) });
+ expect(rejected.ok, `${String(value)}: ${JSON.stringify(rejected.items)}`).toBe(false);
+ }
+ const reviewed = await author(html, { author: { name: 'example/proposal' }, proposal: {
+ ...proposal('641', 480),
+ sourceDecisions: [{ action: 'replace', sourceRef: reference, node: 'image', attribute: 'width', value: '641', reason: 'Approved image crop.' }],
+ } });
+ expect(reviewed.ok, JSON.stringify(reviewed.items)).toBe(true);
+ expect(reviewed.package!.canonicalPlan!.structure[0]!.attributes).toMatchObject({ width: '641', height: '480' });
+ expect(reviewed.package!.canonicalPlan!.sourceDecisions).toEqual(expect.arrayContaining([
+ expect.objectContaining({ action: 'replace', attribute: 'width', original: '640', value: '641' }),
+ ]));
+ });
+
it('accounts for identical source units by reference and audits href aliases', async () => {
const html = '
Discard
Read'; const evidence = collectSourceEvidence(html).structure;