feat: page deletion, redaction and page rotation

This commit is contained in:
Furqan-14
2026-06-08 16:53:02 +05:30
parent ed12275976
commit 5d366132ef
15 changed files with 600 additions and 11 deletions
Binary file not shown.
+4
View File
@@ -169,5 +169,9 @@ PYBIND11_MODULE(pdfengine, m) {
.def("save_incremental", [](const pdfengine::PdfDocument& self) {
std::vector<uint8_t> res = get_or_throw(self.saveIncremental());
return py::bytes(reinterpret_cast<const char*>(res.data()), res.size());
})
.def("save_full", [](const pdfengine::PdfDocument& self) {
std::vector<uint8_t> res = get_or_throw(self.saveFull());
return py::bytes(reinterpret_cast<const char*>(res.data()), res.size());
});
}
@@ -120,6 +120,9 @@ public:
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
saveIncremental() const = 0;
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
saveFull() const = 0;
};
}
+76 -1
View File
@@ -1108,7 +1108,67 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
FPDF_ClosePage(page);
} else if (type == "redaction") {
spdlog::info("Parsed redaction edit operation (stub)");
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("redaction operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
double x = data.value("x", 0.0);
double y = data.value("y", 0.0);
double width = data.value("width", 0.0);
double height = data.value("height", 0.0);
std::string fillColor = data.value("fillColor", "#ffffff");
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for redaction", pageIndex);
return std::unexpected(EngineError::Unknown);
}
// 1. Permanently remove underlying page objects overlapping with selection
int count = FPDFPage_CountObjects(page);
for (int i = count - 1; i >= 0; --i) {
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, i);
if (obj) {
float left = 0, bottom = 0, right = 0, top = 0;
if (FPDFPageObj_GetBounds(obj, &left, &bottom, &right, &top)) {
// Check overlap
if (!(left > x + width || right < x || bottom > y + height || top < y)) {
FPDFPage_RemoveObject(page, obj);
FPDFPageObj_Destroy(obj);
}
}
}
}
// 2. Place white cover rectangle over region
FPDF_PAGEOBJECT rectObj = FPDFPageObj_CreateNewRect(
static_cast<float>(x),
static_cast<float>(y),
static_cast<float>(width),
static_cast<float>(height)
);
if (!rectObj) {
spdlog::error("Failed to create redaction cover rectangle object");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
unsigned int r = 255, g = 255, b = 255;
parseHexColor(fillColor, r, g, b);
FPDFPageObj_SetFillColor(rectObj, r, g, b, 255);
FPDFPath_SetDrawMode(rectObj, FPDF_FILLMODE_WINDING, 0);
FPDFPage_InsertObject(page, rectObj);
// 3. Regenerate page content stream
if (!FPDFPage_GenerateContent(page)) {
spdlog::error("Failed to generate page content after redaction");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
FPDF_ClosePage(page);
} else if (type == "image_overlay") {
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("image_overlay operation missing 'data' object");
@@ -1295,6 +1355,21 @@ std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveIncremental
#endif
}
std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveFull() const {
#ifdef PDFENGINE_WITH_PDFIUM
if (!doc_) {
return std::unexpected(EngineError::Unknown);
}
VectorWriter writer;
if (!FPDF_SaveWithVersion(doc_, &writer, 0, 14)) {
return std::unexpected(EngineError::WriteFailed);
}
return writer.buffer;
#else
return std::unexpected(EngineError::Unknown);
#endif
}
std::expected<std::vector<FontInfo>, EngineError> PdfiumPage::getFonts() const {
#ifdef PDFENGINE_WITH_PDFIUM
if (!page_) {
+1
View File
@@ -75,6 +75,7 @@ public:
std::expected<void, EngineError> applyEdits(const std::string& editsJson) override;
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
std::expected<std::vector<uint8_t>, EngineError> saveFull() const override;
private:
NativeDocHandle doc_ = nullptr;
+63
View File
@@ -435,6 +435,69 @@ TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) {
EXPECT_NE(textRes->find("UniqueEditedTextAnnotation123"), std::string::npos);
}
TEST(DocumentEditTest, ApplyRedactionAndFullSave) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
// Verify text exists initially
{
auto pageRes = doc->getPage(0);
ASSERT_TRUE(pageRes.has_value());
auto textRes = (*pageRes)->extractText();
ASSERT_TRUE(textRes.has_value());
EXPECT_NE(textRes->find("Hello"), std::string::npos);
}
// Redact the entire page bounds to remove all objects
std::string editsJson = R"({
"version": "1.0",
"operations": [
{
"id": "op_redact_test_1",
"type": "redaction",
"pageIndex": 0,
"data": {
"x": 0.0,
"y": 0.0,
"width": 612.0,
"height": 792.0,
"fillColor": "#ffffff"
}
}
]
})";
auto editRes = doc->applyEdits(editsJson);
ASSERT_TRUE(editRes.has_value());
auto saveRes = doc->saveFull();
ASSERT_TRUE(saveRes.has_value());
const auto& savedBytes = *saveRes;
ASSERT_FALSE(savedBytes.empty());
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
ASSERT_TRUE(newDocRes.has_value());
auto newDoc = *newDocRes;
auto newPageRes = newDoc->getPage(0);
ASSERT_TRUE(newPageRes.has_value());
auto newPage = *newPageRes;
auto textRes = newPage->extractText();
ASSERT_TRUE(textRes.has_value());
// The text should be completely gone
EXPECT_EQ(textRes->find("Hello"), std::string::npos);
EXPECT_EQ(textRes->find("world"), std::string::npos);
}
TEST(DocumentEditTest, ApplyImageOverlayAndIncrementalSave) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
+54 -1
View File
@@ -147,7 +147,7 @@ function App() {
setSidebarTab('annotations');
};
const handleRotateClick = async (newRotValue?: number) => {
const handleRotateClick = async (_newRotValue?: number) => {
if (!selectedDocId || !activeDoc) return;
const pageIndex = currentPage;
try {
@@ -258,6 +258,58 @@ function App() {
}
};
const handleRedactArea = async (pageIndex: number, bounds: { x: number; y: number; width: number; height: number }) => {
if (!selectedDocId || !activeDoc) return;
const pageInfo = activeDoc.pages?.[pageIndex];
const pageHeight = pageInfo ? pageInfo.height : 792;
// Convert coordinates: flip Y axis for PDF space (origin bottom-left)
const x = bounds.x / zoom;
const y = pageHeight - (bounds.y + bounds.height) / zoom;
const width = bounds.width / zoom;
const height = bounds.height / zoom;
if (!confirm("Are you sure you want to permanently redact this area? All text, images, and vectors underneath will be permanently deleted from the file structure. This cannot be undone.")) {
return;
}
try {
setIsLoading(true);
const op = {
id: `redact_${Math.random().toString(36).substring(2, 11)}`,
type: 'redaction' as const,
pageIndex: pageIndex,
data: {
x,
y,
width,
height,
fillColor: '#ffffff',
}
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
// Re-fetch document list so sidebar updates
const docs = await gatewayService.listDocuments();
setDocuments(docs);
// Select the new document ID
setSelectedDocId(result.newDocumentId);
// Reset tool back to select
setActiveTool('select');
}
} catch (err) {
console.error('Failed to apply redaction:', err);
alert('Failed to apply redaction. Make sure the gateway is connected.');
} finally {
setIsLoading(false);
}
};
return (
<div className="w-screen h-screen flex flex-col overflow-hidden bg-slate-950 font-sans text-slate-100 antialiased">
{/* Top Navigation / Toolbar */}
@@ -317,6 +369,7 @@ function App() {
searchQuery={searchQuery}
onAnnotationAdded={handleAnnotationAdded}
onPageVisible={setCurrentPage}
onRedactArea={handleRedactArea}
/>
) : (
<div className="flex-1 h-full flex flex-col items-center justify-center bg-slate-900 gap-3 text-slate-400">
+8
View File
@@ -167,6 +167,14 @@ export const Toolbar: React.FC<ToolbarProps> = ({
>
Signature
</button>
<button
onClick={() => onActiveToolChange('redact')}
className={`tool-btn redact ${activeTool === 'redact' ? 'active' : ''}`}
title="Permanently Redact Area"
>
Redact
</button>
</div>
{/* SEARCH BAR */}
+6
View File
@@ -319,6 +319,12 @@ body {
border: 1px solid rgba(79, 70, 229, 0.4);
}
.tool-btn.redact.active {
background: rgba(244, 63, 94, 0.2);
color: #fda4af;
border: 1px solid rgba(244, 63, 94, 0.4);
}
.upload-btn {
display: flex;
align-items: center;
+1 -1
View File
@@ -28,7 +28,7 @@ export interface RenderParams {
rotation: number;
}
import type { Point, Rect } from './coordinateMapping';
import type { Point } from './coordinateMapping';
export interface TextOverlayData {
text: string;
+22 -7
View File
@@ -8,6 +8,7 @@ import { SearchOverlayLayer } from './SearchOverlayLayer';
import type { Rect } from '../lib/coordinateMapping';
import { gatewayService } from '../lib/gatewayService';
import type { PageInfo } from '../lib/gatewayService';
import { RedactionLayer } from './RedactionLayer';
interface PDFViewerProps {
documentId: string;
@@ -19,6 +20,7 @@ interface PDFViewerProps {
searchQuery?: string;
onAnnotationAdded?: (anno: Annotation) => void;
onPageVisible?: (pageIndex: number) => void;
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
}
interface PageLayout {
@@ -44,6 +46,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
searchQuery,
onAnnotationAdded,
onPageVisible,
onRedactArea,
}, ref) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 });
@@ -289,13 +292,25 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
/>
{/* Text Selection Dragging Layer */}
<SelectionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
zoom={zoom}
onTextSelected={(text, bbox) => handleTextSelection(text, bbox)}
/>
{activeTool !== 'redact' && (
<SelectionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
zoom={zoom}
onTextSelected={(text, bbox) => handleTextSelection(text, bbox)}
/>
)}
{/* Redaction Area Selection Layer */}
{activeTool === 'redact' && (
<RedactionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
onRedactionSelected={(bounds) => onRedactArea?.(page.index, bounds)}
/>
)}
{/* signature/ink overlay tool layer */}
<OverlayLayer
+91
View File
@@ -0,0 +1,91 @@
import React, { useState, useRef } from 'react';
import type { Point, Rect } from '../lib/coordinateMapping';
interface RedactionLayerProps {
pageIndex: number;
width: number;
height: number;
onRedactionSelected: (bounds: Rect) => void;
}
export const RedactionLayer: React.FC<RedactionLayerProps> = ({
width,
height,
onRedactionSelected,
}) => {
const [dragStart, setDragStart] = useState<Point | null>(null);
const [redactBox, setRedactBox] = useState<Rect | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setDragStart({ x, y });
setRedactBox({ x, y, width: 0, height: 0 });
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!dragStart || !redactBox || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const newBox: Rect = {
x: Math.min(dragStart.x, x),
y: Math.min(dragStart.y, y),
width: Math.abs(dragStart.x - x),
height: Math.abs(dragStart.y - y),
};
setRedactBox(newBox);
};
const handleMouseUp = () => {
if (redactBox && redactBox.width > 5 && redactBox.height > 5) {
onRedactionSelected(redactBox);
}
setDragStart(null);
setRedactBox(null);
};
return (
<div
ref={containerRef}
className="redaction-layer"
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${width}px`,
height: `${height}px`,
cursor: 'crosshair',
zIndex: 25,
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
{redactBox && (
<div
className="redaction-box-drag"
style={{
position: 'absolute',
left: `${redactBox.x}px`,
top: `${redactBox.y}px`,
width: `${redactBox.width}px`,
height: `${redactBox.height}px`,
border: '2px dashed var(--color-error, #f43f5e)',
backgroundColor: 'rgba(244, 63, 94, 0.15)',
pointerEvents: 'none',
boxShadow: '0 0 12px rgba(244, 63, 94, 0.25)',
borderRadius: '4px',
}}
/>
)}
</div>
);
};
+2 -1
View File
@@ -250,7 +250,8 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
edits_json = json.dumps(req_dict)
doc.apply_edits(edits_json)
new_bytes = doc.save_incremental()
has_redaction = any(op.get("type") == "redaction" for op in req_dict.get("operations", []))
new_bytes = doc.save_full() if has_redaction else doc.save_incremental()
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
+40
View File
@@ -271,6 +271,46 @@ def test_apply_page_rotation_and_incremental_save(client: TestClient):
assert render_resp.headers["content-type"] == "image/png"
def test_apply_redaction_and_full_save(client: TestClient):
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
# Apply redaction edit operation
edits_payload = {
"version": "1.0",
"operations": [
{
"id": "op_route_redact_test_123",
"type": "redaction",
"pageIndex": 0,
"data": {
"x": 0.0,
"y": 0.0,
"width": 612.0,
"height": 792.0,
"fillColor": "#ffffff",
},
}
],
}
edits_resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
assert edits_resp.status_code == 200
payload = edits_resp.json()
assert payload["success"] is True
new_doc_id = payload["newDocumentId"]
assert new_doc_id != doc_id
# Verify text is deleted from the page
text_resp = client.get(f"/documents/{new_doc_id}/pages/0/text")
assert text_resp.status_code == 200
assert "hello" not in text_resp.json()["text"].lower()
assert "world" not in text_resp.json()["text"].lower()
def test_apply_page_deletion_and_incremental_save(client: TestClient):
# We need a 2-page PDF to test deletion
two_pages_pdf = CORPUS_DIR / "basic" / "hello_world_2_pages.pdf"
+229
View File
@@ -0,0 +1,229 @@
import openpyxl
import copy
import shutil
import os
def update_timeline():
excel_path = "PDF Editor Timeline.xlsx"
backup_path = "PDF Editor Timeline.xlsx.bak"
if os.path.exists(excel_path):
shutil.copyfile(excel_path, backup_path)
print(f"Created backup at {backup_path}")
else:
print(f"Error: {excel_path} not found.")
return
wb = openpyxl.load_workbook(excel_path)
tasks_data = {
"FreeType + HarfBuzz wrappers + LRU glyph cache": [
("FreeType Integration", "1 Day", "22-05-26", "22-05-26", "FreeType library initialized; FT_Face successfully loaded from font buffer", "", "Base integration for the entire typography engine.", "Wk 2", "Wk 2", "Critical"),
("HarfBuzz Integration", "1 Day", "22-05-26", "22-05-26", "HarfBuzz library linked; hb_font and hb_buffer created from FT_Face", "", "Required for shaping Unicode and layout engine.", "Wk 2", "Wk 2", "Critical"),
("FontFace Architecture", "1-2 Days", "22-05-26", "22-05-26", "RAII wrapper for FT_Face and hb_font_t; clean memory management", "", "Encapsulates font face lifecycle in C++.", "Wk 2", "Wk 2", "High"),
("FontLoader System", "1 Day", "22-05-26", "22-05-26", "Loads raw font binary data (TTF/OTF) from memory buffer into FontFace", "", "Supports loading from system or custom paths.", "Wk 2", "Wk 2", "High"),
("FontResolver System", "1-2 Days", "22-05-26", "22-05-26", "Resolves font requests (family, weight, italic) to best matching local font", "", "Determines which font to load dynamically.", "Wk 2", "Wk 2", "High"),
("Font Metrics Engine", "1 Day", "22-05-26", "23-05-26", "Calculates ascender, descender, line gap, and scale factors", "", "Ensures vertical text alignment is correct.", "Wk 2", "Wk 3", "High"),
("Advance Width Calculation", "1 Day", "22-05-26", "23-05-26", "Retrieves horizontal advances for individual glyphs via FreeType / HarfBuzz", "", "Needed to correctly advance cursor during text rendering.", "Wk 2", "Wk 3", "High"),
("Glyph Lookup System", "1 Day", "22-05-26", "23-05-26", "Maps unicode character points to glyph indices accurately", "", "Core of character representation.", "Wk 2", "Wk 3", "High"),
("Missing Glyph Detection", "1 Day", "23-05-26", "23-05-26", "Identifies when a font lacks a glyph for a codepoint; triggers fallback", "", "Prevents rendering blank spaces / boxes (tofu).", "Wk 3", "Wk 3", "Med"),
("LRU Glyph Cache", "1-2 Days", "22-05-26", "23-05-26", "Caches rendered glyph bitmaps; cache hits >90% on text corpus", "", "Crucial for text rendering performance.", "Wk 2", "Wk 3", "Critical"),
("Runtime Font Cache", "1 Day", "22-05-26", "23-05-26", "Stores loaded FontFace instances globally to prevent reloading same font resource", "", "Speeds up multi-page renders.", "Wk 2", "Wk 3", "High"),
("Thread-Safe Font Engine", "1 Day", "22-05-26", "23-05-26", "All font engine components are thread-safe; concurrent access protected by mutexes", "", "Supports background parsing and parallel rasterization.", "Wk 2", "Wk 3", "High"),
("Memory Management & RAII", "1 Day", "22-05-26", "22-05-26", "Zero memory leaks under ASan when loading and unloading fonts", "", "Strict enforcement of ownership semantics.", "Wk 2", "Wk 2", "Critical"),
("HarfBuzz Ligature & Kerning Support", "1 Day", "22-05-26", "23-05-26", "Applies advanced open type features (liga, kern) correctly during text shaping", "", "Ensures professional typesetting look.", "Wk 2", "Wk 3", "High"),
("Unicode Text Shaping Pipeline", "2 Days", "22-05-26", "23-05-26", "Shapes incoming UTF-8 string into shaped glyph run with correct advance and offsets", "", "Translates abstract string to concrete spatial coordinates.", "Wk 2", "Wk 3", "Critical")
],
"PDF font resource loader (Type1/TrueType/CID)": [
("PDF Font Resource Loader", "2 Days", "23-05-26", "25-05-26", "Loads font resource streams directly from PDF dictionaries", "", "Essential for rendering documents with embedded fonts.", "Wk 3", "Wk 3", "Critical"),
("TrueType Font Support", "1 Day", "23-05-26", "24-05-26", "Parses and renders standard TrueType (.ttf) fonts", "", "Handles glyph outline rendering for TrueType format.", "Wk 3", "Wk 3", "High"),
("OpenType Font Support", "1 Day", "24-05-26", "24-05-26", "Parses and renders standard OpenType (.otf) fonts", "", "Handles CFF outlines and advanced OpenType tables.", "Wk 3", "Wk 3", "High"),
("Type1 Font Support", "1 Day", "24-05-26", "25-05-26", "Parses and renders legacy Type 1 postscript fonts", "", "Needed for legacy PDF documents.", "Wk 3", "Wk 3", "High"),
("CIDFont Support", "2 Days", "25-05-26", "26-05-26", "Correctly maps character IDs to glyph indices for CIDFonts", "", "Crucial for large character sets (CJK).", "Wk 3", "Wk 4", "Critical"),
("Embedded Font Loading", "1 Day", "23-05-26", "24-05-26", "Extracts and loads embedded font file streams (FontFile, FontFile2, FontFile3)", "", "Prevents font missing errors.", "Wk 3", "Wk 3", "Critical"),
("Subset Font Loading", "1-2 Days", "24-05-26", "26-05-26", "Successfully loads fonts containing only a subset of glyphs", "", "Common optimization in PDFs; requires robust glyph-index mapping.", "Wk 3", "Wk 4", "High"),
("Font Metadata Extraction", "1 Day", "23-05-26", "24-05-26", "Parses font names, flags, subtypes, and panose data", "", "Useful for debugging and font substitution matching.", "Wk 3", "Wk 3", "Med"),
("CJK (Chinese/Japanese/Korean) Support", "2 Days", "24-05-26", "26-05-26", "Successfully renders double-byte character sets and vertical writing parameters", "", "Crucial for international document rendering.", "Wk 3", "Wk 4", "High"),
("PDFium ↔ Font Engine Integration", "2 Days", "24-05-26", "26-05-26", "Synchronizes PDFium's font extraction with our FreeType/HarfBuzz pipeline", "", "Binds the layout interpreter to our rendering path.", "Wk 3", "Wk 4", "Critical"),
("Lazy Font Loading", "1 Day", "24-05-26", "26-05-26", "Loads font resource streams only when a glyph from that font needs to be rendered", "", "Optimizes document open times and memory consumption.", "Wk 3", "Wk 4", "Med")
],
"Font substitution system (Liberation fonts)": [
("Font Fallback System", "1-2 Days", "25-05-26", "27-05-26", "Dynamically selects a fallback font when primary font lacks glyph", "", "Ensures all characters render.", "Wk 4", "Wk 4", "High"),
("Font Substitution System (Liberation Fonts)", "2 Days", "25-05-26", "01-06-26", "Substitutes missing system fonts (e.g. Arial) with metric-compatible Liberation fonts", "", "Keeps layout intact.", "Wk 4", "Wk 4", "High")
]
}
def copy_cell_style(src_cell, dest_cell):
if src_cell.has_style:
dest_cell.font = copy.copy(src_cell.font)
dest_cell.fill = copy.copy(src_cell.fill)
dest_cell.border = copy.copy(src_cell.border)
dest_cell.alignment = copy.copy(src_cell.alignment)
dest_cell.number_format = copy.copy(src_cell.number_format)
dest_cell.protection = copy.copy(src_cell.protection)
# 1. Update 📋 Master Timeline
ws = wb['📋 Master Timeline']
headers = [ws.cell(row=6, column=col).value for col in range(1, ws.max_column + 1)]
has_dates = "Start Date" in headers
if not has_dates:
print("Inserting Start Date and End Date columns in Master Timeline...")
# Insert 2 columns at Column K (index 11)
ws.insert_cols(11, 2)
ws.cell(row=6, column=11).value = "Start Date"
ws.cell(row=6, column=12).value = "End Date"
headers = [ws.cell(row=6, column=col).value for col in range(1, ws.max_column + 1)]
copy_cell_style(ws.cell(row=6, column=10), ws.cell(row=6, column=11))
copy_cell_style(ws.cell(row=6, column=10), ws.cell(row=6, column=12))
parent_rows = {}
for r in range(7, ws.max_row + 1):
task_name = ws.cell(row=r, column=3).value
if task_name in tasks_data:
parent_rows[task_name] = r
sorted_parents = sorted(parent_rows.items(), key=lambda x: x[1], reverse=True)
total_inserted = 0
for parent_name, row_idx in sorted_parents:
subtasks = tasks_data[parent_name]
num_sub = len(subtasks)
print(f"Found parent '{parent_name}' at row {row_idx}. Inserting {num_sub} subtasks below it.")
ws.insert_rows(row_idx + 1, num_sub)
for i, sub in enumerate(subtasks):
curr_row = row_idx + 1 + i
task_t, dur, sd, ed, sc, dep, notes, wk_s, wk_e, pri = sub
ws.cell(row=curr_row, column=1).value = "P1"
ws.cell(row=curr_row, column=2).value = "Phase 1"
ws.cell(row=curr_row, column=3).value = f" - {task_t}" # indented slightly for readability
ws.cell(row=curr_row, column=4).value = "Dev 3"
ws.cell(row=curr_row, column=5).value = wk_s
ws.cell(row=curr_row, column=6).value = wk_e
ws.cell(row=curr_row, column=7).value = dur
ws.cell(row=curr_row, column=8).value = "Done"
ws.cell(row=curr_row, column=9).value = pri
ws.cell(row=curr_row, column=10).value = 1.0 # 100% done
ws.cell(row=curr_row, column=11).value = sd
ws.cell(row=curr_row, column=12).value = ed
ws.cell(row=curr_row, column=13).value = sc
ws.cell(row=curr_row, column=14).value = dep if dep else None
ws.cell(row=curr_row, column=15).value = notes
for col in range(1, 16):
copy_cell_style(ws.cell(row=row_idx, column=col), ws.cell(row=curr_row, column=col))
total_inserted += num_sub
summary_row_idx = None
for r in range(7, ws.max_row + 1):
val = ws.cell(row=r, column=1).value
if val and isinstance(val, str) and "=COUNTIF" in val:
summary_row_idx = r
break
if summary_row_idx:
print(f"Summary row found at {summary_row_idx}. Updating formulas to include new rows.")
old_end_row = 69
new_end_row = 69 + total_inserted
# Update formulas
# A: Dev counts formula
# D: Priority counts formula
# H: Status counts formula
# K: average completion formula
# A
f_a = ws.cell(row=summary_row_idx, column=1).value
ws.cell(row=summary_row_idx, column=1).value = f_a.replace(f"D{old_end_row}", f"D{new_end_row}")
# D
f_d = ws.cell(row=summary_row_idx, column=4).value
ws.cell(row=summary_row_idx, column=4).value = f_d.replace(f"I{old_end_row}", f"I{new_end_row}")
# H
f_h = ws.cell(row=summary_row_idx, column=8).value
ws.cell(row=summary_row_idx, column=8).value = f_h.replace(f"H{old_end_row}", f"H{new_end_row}")
# K -> shifted to M (13)
f_k = ws.cell(row=summary_row_idx, column=13).value
if f_k and "J7:J69" in f_k:
ws.cell(row=summary_row_idx, column=13).value = f_k.replace(f"J{old_end_row}", f"J{new_end_row}")
print("Master Timeline formulas updated successfully.")
if '🔍 Phase Detail' in wb.sheetnames:
ws_det = wb['🔍 Phase Detail']
parent_rows_det = {}
for r in range(7, ws_det.max_row + 1):
task_name = ws_det.cell(row=r, column=3).value
if task_name in tasks_data:
parent_rows_det[task_name] = r
sorted_parents_det = sorted(parent_rows_det.items(), key=lambda x: x[1], reverse=True)
for parent_name, row_idx in sorted_parents_det:
subtasks = tasks_data[parent_name]
num_sub = len(subtasks)
ws_det.insert_rows(row_idx + 1, num_sub)
for i, sub in enumerate(subtasks):
curr_row = row_idx + 1 + i
task_t, dur, sd, ed, sc, dep, notes, wk_s, wk_e, pri = sub
ws_det.cell(row=curr_row, column=1).value = "P1"
ws_det.cell(row=curr_row, column=2).value = "Phase 1"
ws_det.cell(row=curr_row, column=3).value = f" - {task_t}"
ws_det.cell(row=curr_row, column=4).value = "Dev 3"
ws_det.cell(row=curr_row, column=5).value = wk_s
ws_det.cell(row=curr_row, column=6).value = wk_e
ws_det.cell(row=curr_row, column=7).value = dur
ws_det.cell(row=curr_row, column=8).value = "Done"
ws_det.cell(row=curr_row, column=9).value = pri
ws_det.cell(row=curr_row, column=10).value = 1.0
ws_det.cell(row=curr_row, column=11).value = sc
for col in range(1, 12):
copy_cell_style(ws_det.cell(row=row_idx, column=col), ws_det.cell(row=curr_row, column=col))
print("Phase Detail sheet updated successfully.")
if '📅 Gantt' in wb.sheetnames:
ws_gantt = wb['📅 Gantt']
parent_rows_g = {}
for r in range(5, ws_gantt.max_row + 1):
task_name = ws_gantt.cell(row=r, column=2).value
if task_name in tasks_data:
parent_rows_g[task_name] = r
sorted_parents_g = sorted(parent_rows_g.items(), key=lambda x: x[1], reverse=True)
for parent_name, row_idx in sorted_parents_g:
subtasks = tasks_data[parent_name]
num_sub = len(subtasks)
ws_gantt.insert_rows(row_idx + 1, num_sub)
for i, sub in enumerate(subtasks):
curr_row = row_idx + 1 + i
task_t, dur, sd, ed, sc, dep, notes, wk_s, wk_e, pri = sub
ws_gantt.cell(row=curr_row, column=1).value = "P1"
ws_gantt.cell(row=curr_row, column=2).value = f" - {task_t}"
ws_gantt.cell(row=curr_row, column=3).value = "Dev 3"
ws_gantt.cell(row=curr_row, column=4).value = dur
wk_s_num = int(wk_s.split()[1]) # 'Wk 2' -> 2
wk_e_num = int(wk_e.split()[1]) # 'Wk 3' -> 3
for wk in range(1, 21):
col_idx = 4 + wk
if wk_s_num <= wk <= wk_e_num:
ws_gantt.cell(row=curr_row, column=col_idx).value = ""
else:
ws_gantt.cell(row=curr_row, column=col_idx).value = None
for col in range(1, 25):
copy_cell_style(ws_gantt.cell(row=row_idx, column=col), ws_gantt.cell(row=curr_row, column=col))
print("Gantt sheet updated successfully.")
wb.save(excel_path)
print(f"Successfully saved updated timeline to {excel_path}!")
if __name__ == "__main__":
update_timeline()