From 36190f1ba507530ff708c3312db930196d46a5aa Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Sat, 22 Mar 2025 17:12:50 +0100 Subject: [PATCH] Improve title detection algorithm and UI layout in InsertTitles component --- src/taskpane/components/InsertTitles.tsx | 258 +++++++++++++++++------ 1 file changed, 199 insertions(+), 59 deletions(-) diff --git a/src/taskpane/components/InsertTitles.tsx b/src/taskpane/components/InsertTitles.tsx index 567930ed..a7ff5f42 100644 --- a/src/taskpane/components/InsertTitles.tsx +++ b/src/taskpane/components/InsertTitles.tsx @@ -6,12 +6,17 @@ import ActionButton from "./ActionButton"; import { getErrorMessage } from "../types/office-types"; // Configuration constants -const TITLE_POSITION_THRESHOLD = 150; // Pixels from top of slide to consider a shape as a title +const TITLE_POSITION_THRESHOLD = 95; // Pixels from top of slide to consider a shape as a title const INCLUDE_SLIDE_NUMBERS = false; // Whether to include slide numbers in the output +const STANDARD_SLIDE_WIDTH = 720; // Standard PowerPoint slide width + +// Title detection methods +type TitleDetectionMethod = "auto" | "position" | "first" | "name"; // Title collection options interface TitleCollectionOptions { includeSlideNumbers: boolean; + detectionMethod?: TitleDetectionMethod; } // Title collection result @@ -20,9 +25,21 @@ interface TitleCollectionResult { titlesCollected: number; } +// Title candidate with position and size information +interface TitleCandidate { + text: string; + top: number; + left: number; + width: number; + height: number; + fontSize?: number; + isBold?: boolean; +} + export const InsertTitles: React.FC = () => { const styles = useCommonStyles(); const { setStatusMessage, setStatusType } = useStatusContext(); + const [titleDetectionMethod, setTitleDetectionMethod] = React.useState("auto"); /** * Validates that a text box is selected @@ -57,6 +74,159 @@ export const InsertTitles: React.FC = () => { return selectedShape; }; + /** + * Finds the title shape on a slide using multiple heuristics + * @param shapes The collection of shapes on a slide + * @param context The PowerPoint request context + * @param slideNumber The slide number (for logging) + * @param method The title detection method to use + * @returns The title shape text or null if not found + */ + const findTitleShape = async ( + shapes: PowerPoint.ShapeCollection, + context: PowerPoint.RequestContext, + slideNumber: number, + method: TitleDetectionMethod = "auto" + ): Promise<{ text: string } | null> => { + try { + let titleCandidates: TitleCandidate[] = []; + + // First pass: look for title shapes by name + // This is the most reliable method if available + if (method === "auto" || method === "name") { + for (let j = 0; j < shapes.items.length; j++) { + const shape = shapes.items[j]; + + // Check if this shape has a name that indicates it's a title + shape.load("name,textFrame"); + await context.sync(); + + // Check if this might be a title shape by name + if (shape.name && + (shape.name.toLowerCase().includes("title") || + shape.name.toLowerCase().includes("heading"))) { + + if (shape.textFrame) { + shape.textFrame.load("textRange"); + await context.sync(); + + if (shape.textFrame.textRange) { + shape.textFrame.textRange.load("text"); + await context.sync(); + + const shapeText = shape.textFrame.textRange.text; + if (shapeText && shapeText.trim() !== "") { + return { text: shapeText }; + } + } + } + } + } + } + + // If we're only looking for title shapes by name and didn't find any, return null + if (method === "name") { + return null; + } + + // Second pass: collect all text shapes based on the selected method + for (let j = 0; j < shapes.items.length; j++) { + const shape = shapes.items[j]; + + // Load basic properties for all shapes + shape.load("textFrame,top,left,width,height"); + await context.sync(); + + if (shape.textFrame) { + shape.textFrame.load("textRange"); + await context.sync(); + + if (shape.textFrame.textRange) { + shape.textFrame.textRange.load("text,font/size,font/bold"); + await context.sync(); + + const shapeText = shape.textFrame.textRange.text; + + if (shapeText && shapeText.trim() !== "") { + // For "first" method, return the first text shape we find + if (method === "first") { + return { text: shapeText }; + } + + // For position method or auto, check position + if (method === "position" || method === "auto") { + // Use position as a heuristic for identifying titles + if (shape.top < TITLE_POSITION_THRESHOLD) { + // For position-only method, return the first match + if (method === "position") { + return { text: shapeText }; + } + + // For auto method, add to candidates with priority score + const fontSize = shape.textFrame.textRange.font.size || 12; + const isBold = shape.textFrame.textRange.font.bold || false; + + titleCandidates.push({ + text: shapeText, + top: shape.top, + left: shape.left, + width: shape.width, + height: shape.height, + fontSize: fontSize, + isBold: isBold + }); + } + } + } + } + } + } + + // If we have candidates, sort them by position and size heuristics + if (titleCandidates.length > 0) { + // First sort by vertical position (top-most first) + titleCandidates.sort((a, b) => a.top - b.top); + + // Then refine by other heuristics for the top candidates + // Only consider shapes that are close to the top + const topCandidates = titleCandidates.filter( + c => c.top <= titleCandidates[0].top + 50 + ); + + if (topCandidates.length > 1) { + // Sort by a combination of factors: + // 1. How centered the shape is + // 2. Font size (larger is better) + // 3. Bold text is preferred + // 4. Width (wider is better for titles) + topCandidates.sort((a, b) => { + // Calculate how centered each shape is + const aCenterOffset = Math.abs((a.left + a.width/2) - (STANDARD_SLIDE_WIDTH/2)); + const bCenterOffset = Math.abs((b.left + b.width/2) - (STANDARD_SLIDE_WIDTH/2)); + + // Calculate font score (size + bold bonus) + const aFontScore = (a.fontSize || 12) * (a.isBold ? 1.5 : 1); + const bFontScore = (b.fontSize || 12) * (b.isBold ? 1.5 : 1); + + // Combine factors into a single score (lower is better) + // Weight factors by importance + const aScore = aCenterOffset * 2 - aFontScore * 5 - a.width * 0.5; + const bScore = bCenterOffset * 2 - bFontScore * 5 - b.width * 0.5; + + return aScore - bScore; + }); + } + + // Return the best candidate + return { text: topCandidates[0].text }; + } + } catch (error) { + console.error(`Error finding title on slide ${slideNumber}:`, getErrorMessage(error)); + } + + return null; + }; + /** * Collects titles from all slides in the presentation * @param slides The collection of slides @@ -67,7 +237,10 @@ export const InsertTitles: React.FC = () => { const collectSlideTitles = async ( slides: PowerPoint.SlideCollection, context: PowerPoint.RequestContext, - options: TitleCollectionOptions = { includeSlideNumbers: INCLUDE_SLIDE_NUMBERS } + options: TitleCollectionOptions = { + includeSlideNumbers: INCLUDE_SLIDE_NUMBERS, + detectionMethod: "auto" + } ): Promise => { let titleText = ""; let titlesCollected = 0; @@ -86,7 +259,12 @@ export const InsertTitles: React.FC = () => { await context.sync(); // Find the title shape on this slide - const titleShape = await findTitleShape(shapes, context, i + 1); + const titleShape = await findTitleShape( + shapes, + context, + i + 1, + options.detectionMethod || "auto" + ); if (titleShape) { // Format the title text based on options @@ -107,61 +285,6 @@ export const InsertTitles: React.FC = () => { return { titleText, titlesCollected }; }; - /** - * Finds the title shape on a slide - * @param shapes The collection of shapes on a slide - * @param context The PowerPoint request context - * @param slideNumber The slide number (for logging) - * @returns The title shape text or null if not found - */ - const findTitleShape = async ( - shapes: PowerPoint.ShapeCollection, - context: PowerPoint.RequestContext, - slideNumber: number - ): Promise<{ text: string } | null> => { - try { - // Batch load textFrame for all shapes - for (let j = 0; j < shapes.items.length; j++) { - shapes.items[j].load("textFrame"); - } - await context.sync(); - - // Process shapes with text frames - for (let j = 0; j < shapes.items.length; j++) { - const shape = shapes.items[j]; - - if (shape.textFrame) { - // Load text range for shapes with text frames - shape.textFrame.load("textRange"); - await context.sync(); - - if (shape.textFrame.textRange) { - shape.textFrame.textRange.load("text"); - await context.sync(); - - const shapeText = shape.textFrame.textRange.text; - - // Check if the shape has text - if (shapeText && shapeText.trim() !== "") { - // Load position to check if it's at the top - shape.load("top"); - await context.sync(); - - // Use position as a heuristic for identifying titles - if (shape.top < TITLE_POSITION_THRESHOLD) { - return { text: shapeText }; - } - } - } - } - } - } catch (error) { - console.error(`Error finding title on slide ${slideNumber}:`, getErrorMessage(error)); - } - - return null; - }; - /** * Inserts collected titles into a shape * @param shape The shape to insert titles into @@ -235,7 +358,10 @@ export const InsertTitles: React.FC = () => { const { titleText, titlesCollected } = await collectSlideTitles( slides, context, - { includeSlideNumbers: INCLUDE_SLIDE_NUMBERS } + { + includeSlideNumbers: INCLUDE_SLIDE_NUMBERS, + detectionMethod: titleDetectionMethod + } ); // Insert titles if any were found @@ -268,6 +394,20 @@ export const InsertTitles: React.FC = () => { onClick={collectAndInsertTitles} /> +
+ +
); };