/** * MiCompress Add-on — Google Docs Image Handler * * Handles scanning and replacing images in Google Docs documents. * Google Docs uses InlineImage elements within the document body. */ /** * Scan all images in the active Google Document * * @returns {Array} Array of image objects with blob, size, name, dimensions */ function scanDocsImages() { const doc = DocumentApp.getActiveDocument(); if (!doc) { throw new Error('No active document found'); } const images = []; // index numbers the images shown to the user; position counts EVERY inline // image, tiny ones included. compressAllImages resumes batches by position, // and an image that compresses below the 1KB cut-off must not shift the // positions of the images after it. const counter = { index: 0, position: 0 }; // Scan through all elements in the document body scanDocsElement_(doc.getBody(), images, counter); // Headers and footers hold images too — often a full-width logo repeated on // every page, which is exactly the kind of thing worth compressing. Either // may be absent, in which case the getter returns null. const header = doc.getHeader(); if (header) { scanDocsElement_(header, images, counter); } const footer = doc.getFooter(); if (footer) { scanDocsElement_(footer, images, counter); } return images; } /** * Recursively scan document elements for inline images * * @param {Element} element - Document element to scan * @param {Array} images - Array to push found images into * @param {{index: number, position: number}} counter - Running counters, updated in place */ function scanDocsElement_(element, images, counter) { const numChildren = element.getNumChildren ? element.getNumChildren() : 0; for (let i = 0; i < numChildren; i++) { const child = element.getChild(i); const type = child.getType(); if (type === DocumentApp.ElementType.INLINE_IMAGE) { const inlineImage = child.asInlineImage(); const blob = inlineImage.getBlob(); const bytes = blob.getBytes(); const position = counter.position++; // Skip tiny images (< 1KB) — likely hidden decorative elements, spacers, or bullets if (bytes.length < 1024) { continue; } counter.index++; images.push({ name: 'Image ' + counter.index, position: position, blob: blob, size: bytes.length, contentType: blob.getContentType() || 'image/png', width: inlineImage.getWidth(), height: inlineImage.getHeight(), element: inlineImage, elementIndex: i, parent: element, type: 'docs', }); } else if (type === DocumentApp.ElementType.TABLE) { // Scan table cells for images const table = child.asTable(); for (let row = 0; row < table.getNumRows(); row++) { const tableRow = table.getRow(row); for (let cell = 0; cell < tableRow.getNumCells(); cell++) { const tableCell = tableRow.getCell(cell); scanDocsElement_(tableCell, images, counter); } } } else if (child.getNumChildren && child.getNumChildren() > 0) { // Recursively scan nested elements (paragraphs, list items, etc.) scanDocsElement_(child, images, counter); } } } /** * Replace an inline image in a Google Doc with a compressed version * * Google Docs has no in-place replaceImage(), so the old image is removed and * a new one inserted at the same index. Everything that lives on the element * rather than in the pixels — size and alt text — has to be carried across by * hand, because the inserted image is a brand new element. * * @param {InlineImage} originalImage - The original InlineImage element * @param {Blob} compressedBlob - The compressed image blob * @returns {InlineImage} The image now in the document */ function replaceDocsImage(originalImage, compressedBlob) { try { // Capture everything worth preserving BEFORE the element leaves the document const originalWidth = originalImage.getWidth(); const originalHeight = originalImage.getHeight(); const altTitle = safeGet_(originalImage, 'getAltTitle'); const altDescription = safeGet_(originalImage, 'getAltDescription'); const parent = originalImage.getParent(); if (!parent) { throw new Error('Cannot find parent element of image'); } const parentType = parent.getType(); let container = null; if (parentType === DocumentApp.ElementType.PARAGRAPH) { container = parent.asParagraph(); } else if (parentType === DocumentApp.ElementType.LIST_ITEM) { container = parent.asListItem(); } let newImage; if (container) { // getChildIndex() identifies THIS element. The previous implementation // matched on content type instead, so in a paragraph holding several // images of the same type it always returned the first one's index and // shuffled the images around. const childIndex = container.getChildIndex(originalImage); originalImage.removeFromParent(); if (childIndex >= 0 && childIndex <= container.getNumChildren()) { newImage = container.insertInlineImage(childIndex, compressedBlob); } else { newImage = container.appendInlineImage(compressedBlob); } } else { // Unexpected parent (e.g. a table cell holding the image directly): // append to the body rather than losing the image altogether. originalImage.removeFromParent(); newImage = DocumentApp.getActiveDocument().getBody().appendImage(compressedBlob); } if (originalWidth && originalHeight) { newImage.setWidth(originalWidth); newImage.setHeight(originalHeight); } if (altTitle) { newImage.setAltTitle(altTitle); } if (altDescription) { newImage.setAltDescription(altDescription); } return newImage; } catch (err) { console.error('replaceDocsImage error:', err); throw new Error('Failed to replace image in document: ' + err.message); } } /** * Call a getter that may not exist on every Docs element version, returning * null instead of throwing. */ function safeGet_(element, methodName) { try { return element[methodName] ? element[methodName]() : null; } catch (e) { return null; } }