Improve title detection algorithm and UI layout in InsertTitles component
This commit is contained in:
@@ -6,12 +6,17 @@ import ActionButton from "./ActionButton";
|
|||||||
import { getErrorMessage } from "../types/office-types";
|
import { getErrorMessage } from "../types/office-types";
|
||||||
|
|
||||||
// Configuration constants
|
// 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 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
|
// Title collection options
|
||||||
interface TitleCollectionOptions {
|
interface TitleCollectionOptions {
|
||||||
includeSlideNumbers: boolean;
|
includeSlideNumbers: boolean;
|
||||||
|
detectionMethod?: TitleDetectionMethod;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title collection result
|
// Title collection result
|
||||||
@@ -20,9 +25,21 @@ interface TitleCollectionResult {
|
|||||||
titlesCollected: number;
|
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 = () => {
|
export const InsertTitles: React.FC = () => {
|
||||||
const styles = useCommonStyles();
|
const styles = useCommonStyles();
|
||||||
const { setStatusMessage, setStatusType } = useStatusContext();
|
const { setStatusMessage, setStatusType } = useStatusContext();
|
||||||
|
const [titleDetectionMethod, setTitleDetectionMethod] = React.useState<TitleDetectionMethod>("auto");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a text box is selected
|
* Validates that a text box is selected
|
||||||
@@ -57,6 +74,159 @@ export const InsertTitles: React.FC = () => {
|
|||||||
return selectedShape;
|
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
|
* Collects titles from all slides in the presentation
|
||||||
* @param slides The collection of slides
|
* @param slides The collection of slides
|
||||||
@@ -67,7 +237,10 @@ export const InsertTitles: React.FC = () => {
|
|||||||
const collectSlideTitles = async (
|
const collectSlideTitles = async (
|
||||||
slides: PowerPoint.SlideCollection,
|
slides: PowerPoint.SlideCollection,
|
||||||
context: PowerPoint.RequestContext,
|
context: PowerPoint.RequestContext,
|
||||||
options: TitleCollectionOptions = { includeSlideNumbers: INCLUDE_SLIDE_NUMBERS }
|
options: TitleCollectionOptions = {
|
||||||
|
includeSlideNumbers: INCLUDE_SLIDE_NUMBERS,
|
||||||
|
detectionMethod: "auto"
|
||||||
|
}
|
||||||
): Promise<TitleCollectionResult> => {
|
): Promise<TitleCollectionResult> => {
|
||||||
let titleText = "";
|
let titleText = "";
|
||||||
let titlesCollected = 0;
|
let titlesCollected = 0;
|
||||||
@@ -86,7 +259,12 @@ export const InsertTitles: React.FC = () => {
|
|||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
// Find the title shape on this slide
|
// 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) {
|
if (titleShape) {
|
||||||
// Format the title text based on options
|
// Format the title text based on options
|
||||||
@@ -107,61 +285,6 @@ export const InsertTitles: React.FC = () => {
|
|||||||
return { titleText, titlesCollected };
|
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
|
* Inserts collected titles into a shape
|
||||||
* @param shape The shape to insert titles into
|
* @param shape The shape to insert titles into
|
||||||
@@ -235,7 +358,10 @@ export const InsertTitles: React.FC = () => {
|
|||||||
const { titleText, titlesCollected } = await collectSlideTitles(
|
const { titleText, titlesCollected } = await collectSlideTitles(
|
||||||
slides,
|
slides,
|
||||||
context,
|
context,
|
||||||
{ includeSlideNumbers: INCLUDE_SLIDE_NUMBERS }
|
{
|
||||||
|
includeSlideNumbers: INCLUDE_SLIDE_NUMBERS,
|
||||||
|
detectionMethod: titleDetectionMethod
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Insert titles if any were found
|
// Insert titles if any were found
|
||||||
@@ -268,6 +394,20 @@ export const InsertTitles: React.FC = () => {
|
|||||||
onClick={collectAndInsertTitles}
|
onClick={collectAndInsertTitles}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
|
<select
|
||||||
|
value={titleDetectionMethod}
|
||||||
|
onChange={(e) => setTitleDetectionMethod(e.target.value as TitleDetectionMethod)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
aria-label="Title detection method"
|
||||||
|
title="Select title detection method"
|
||||||
|
>
|
||||||
|
<option value="auto">Auto-detect titles</option>
|
||||||
|
<option value="name">Title shapes by name</option>
|
||||||
|
<option value="position">Position-based detection</option>
|
||||||
|
<option value="first">First text on slide</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user