gdocs-mcp 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,11 @@ import { getPreset } from '../presets/config.js';
5
5
  const DEFAULT_HEADER_BG = { red: 0.043, green: 0.325, blue: 0.58 };
6
6
  const DEFAULT_HEADER_TEXT = { red: 0.95, green: 0.95, blue: 0.95 };
7
7
  const DEFAULT_PADDING = 5;
8
- const USABLE_PAGE_WIDTH = 468; // 612 - 72 - 72 (letter, 1in margins)
9
- const MAX_COL_RATIO = 0.35;
8
+ const DEFAULT_PAGE_WIDTH = 612;
9
+ const DEFAULT_MARGIN = 72;
10
+ const DEFAULT_FONT_FAMILY = 'Arial';
11
+ const DEFAULT_FONT_SIZE = 11;
12
+ const DEFAULT_LINE_SPACING = 115;
10
13
  const PT_PER_CHAR = 6;
11
14
  const PADDING_PER_COL = 20;
12
15
  export const WriteTableSchema = z.object({
@@ -33,16 +36,31 @@ export async function writeTable(args) {
33
36
  const docs = google.docs({ version: 'v1', auth });
34
37
  const apiStart = performance.now();
35
38
  const colCount = args.headers.length;
36
- const rowCount = args.rows.length + 1; // headers + data rows
39
+ const rowCount = args.rows.length + 1;
37
40
  if (colCount > 20 || rowCount > 20) {
38
41
  throw new Error(`Table exceeds Google Docs limit of 20x20. Requested: ${rowCount} rows x ${colCount} columns.`);
39
42
  }
40
- // Validate row lengths match header count
41
43
  for (let i = 0; i < args.rows.length; i++) {
42
44
  if (args.rows[i].length !== colCount) {
43
45
  throw new Error(`Row ${i} has ${args.rows[i].length} cells but expected ${colCount} (matching headers).`);
44
46
  }
45
47
  }
48
+ // Step 0: Read document style for actual page dimensions + body font
49
+ const docMeta = await docs.documents.get({
50
+ documentId: args.documentId,
51
+ fields: 'documentStyle,namedStyles',
52
+ });
53
+ const docStyle = docMeta.data.documentStyle || {};
54
+ const pageWidth = docStyle.pageSize?.width?.magnitude ?? DEFAULT_PAGE_WIDTH;
55
+ const marginLeft = docStyle.marginLeft?.magnitude ?? DEFAULT_MARGIN;
56
+ const marginRight = docStyle.marginRight?.magnitude ?? DEFAULT_MARGIN;
57
+ const usableWidth = pageWidth - marginLeft - marginRight;
58
+ // Get body font from named styles
59
+ const namedStyles = docMeta.data.namedStyles?.styles || [];
60
+ const normalTextStyle = namedStyles.find((s) => s.namedStyleType === 'NORMAL_TEXT');
61
+ const bodyFontFamily = normalTextStyle?.textStyle?.weightedFontFamily?.fontFamily ?? DEFAULT_FONT_FAMILY;
62
+ const bodyFontSize = normalTextStyle?.textStyle?.fontSize?.magnitude ?? DEFAULT_FONT_SIZE;
63
+ const bodyLineSpacing = normalTextStyle?.paragraphStyle?.lineSpacing ?? DEFAULT_LINE_SPACING;
46
64
  // Step 1: Insert empty table
47
65
  await docs.documents.batchUpdate({
48
66
  documentId: args.documentId,
@@ -68,7 +86,7 @@ export async function writeTable(args) {
68
86
  'Document may have been modified concurrently.');
69
87
  }
70
88
  const tableStartIndex = table.startIndex;
71
- // Build all cell data: [row][col] = { insertIndex, text }
89
+ // Build all cell data
72
90
  const allData = [args.headers, ...args.rows];
73
91
  const cellInserts = [];
74
92
  for (let r = 0; r < table.table.tableRows.length; r++) {
@@ -78,23 +96,21 @@ export async function writeTable(args) {
78
96
  cellInserts.push({ index: insertAt, text: allData[r][c] });
79
97
  }
80
98
  }
81
- // Step 3: Build all requests — insert text (reverse order), then style
99
+ // Step 3: Insert text in reverse order
82
100
  const requests = [];
83
- // Insert text in reverse order to avoid index shifts
84
101
  const sortedInserts = [...cellInserts].sort((a, b) => b.index - a.index);
85
102
  for (const { index, text } of sortedInserts) {
86
103
  if (text) {
87
104
  requests.push({ insertText: { location: { index }, text } });
88
105
  }
89
106
  }
90
- // Execute text insertion first (indices shift after this)
91
107
  if (requests.length > 0) {
92
108
  await docs.documents.batchUpdate({
93
109
  documentId: args.documentId,
94
110
  requestBody: { requests },
95
111
  });
96
112
  }
97
- // Step 4: Re-read again for styling (indices changed after text insertion)
113
+ // Step 4: Re-read for styling
98
114
  const doc2 = await docs.documents.get({ documentId: args.documentId });
99
115
  let table2 = null;
100
116
  for (const element of doc2.data.body?.content || []) {
@@ -103,7 +119,6 @@ export async function writeTable(args) {
103
119
  break;
104
120
  }
105
121
  }
106
- // Find nearest table if exact match failed (startIndex may have shifted)
107
122
  if (!table2) {
108
123
  for (const element of doc2.data.body?.content || []) {
109
124
  if (element.table && element.startIndex != null && element.startIndex >= args.index) {
@@ -115,25 +130,42 @@ export async function writeTable(args) {
115
130
  if (!table2) {
116
131
  return { documentId: args.documentId, tableCreated: true, styled: false, reason: 'Could not re-locate table for styling' };
117
132
  }
118
- // Resolve style
119
133
  const resolvedStyle = resolveStyle(args.style);
120
134
  const styleRequests = [];
121
- // Bold header text + white color
122
- const headerRow = table2.table.tableRows[0];
123
- for (const cell of headerRow.tableCells) {
124
- const start = cell.content[0].startIndex;
125
- const end = cell.content[cell.content.length - 1].endIndex - 1;
126
- if (end > start) {
135
+ // FIX 1 + 3: Apply explicit font to ALL cells (header + body)
136
+ for (let r = 0; r < table2.table.tableRows.length; r++) {
137
+ for (const cell of table2.table.tableRows[r].tableCells) {
138
+ const start = cell.content[0].startIndex;
139
+ const end = cell.content[cell.content.length - 1].endIndex - 1;
140
+ if (end <= start)
141
+ continue;
142
+ const isHeader = r === 0;
127
143
  styleRequests.push({
128
144
  updateTextStyle: {
129
145
  range: { startIndex: start, endIndex: end },
130
146
  textStyle: {
131
- bold: true,
132
- foregroundColor: { color: { rgbColor: resolvedStyle.headerTextRgb } },
147
+ weightedFontFamily: { fontFamily: bodyFontFamily },
148
+ fontSize: { magnitude: bodyFontSize, unit: 'PT' },
149
+ bold: isHeader,
150
+ foregroundColor: isHeader
151
+ ? { color: { rgbColor: resolvedStyle.headerTextRgb } }
152
+ : undefined,
133
153
  },
134
- fields: 'bold,foregroundColor',
154
+ fields: isHeader
155
+ ? 'weightedFontFamily,fontSize,bold,foregroundColor'
156
+ : 'weightedFontFamily,fontSize,bold',
135
157
  },
136
158
  });
159
+ // Apply line spacing to body cells
160
+ if (!isHeader) {
161
+ styleRequests.push({
162
+ updateParagraphStyle: {
163
+ range: { startIndex: start, endIndex: end },
164
+ paragraphStyle: { lineSpacing: bodyLineSpacing },
165
+ fields: 'lineSpacing',
166
+ },
167
+ });
168
+ }
137
169
  }
138
170
  }
139
171
  // Header row background
@@ -176,7 +208,7 @@ export async function writeTable(args) {
176
208
  fields: 'paddingTop,paddingBottom,paddingLeft,paddingRight',
177
209
  },
178
210
  });
179
- // Content-aware column widths
211
+ // FIX 2 + 4: Content-aware column widths that sum to full usable page width
180
212
  const maxLengths = new Array(colCount).fill(0);
181
213
  for (const row of allData) {
182
214
  for (let c = 0; c < colCount; c++) {
@@ -185,24 +217,31 @@ export async function writeTable(args) {
185
217
  }
186
218
  const totalLen = maxLengths.reduce((a, b) => a + b, 0);
187
219
  if (totalLen > 0) {
220
+ // Calculate raw proportional widths
221
+ const rawWidths = maxLengths.map(l => {
222
+ const contentWidth = l * PT_PER_CHAR + PADDING_PER_COL;
223
+ return Math.max(contentWidth, 50); // minimum 50pt per column
224
+ });
225
+ // Scale all widths proportionally to sum to exactly usableWidth
226
+ const rawTotal = rawWidths.reduce((a, b) => a + b, 0);
227
+ const scaledWidths = rawWidths.map(w => Math.round((w / rawTotal) * usableWidth));
228
+ // Adjust rounding error on last column
229
+ const scaledTotal = scaledWidths.reduce((a, b) => a + b, 0);
230
+ scaledWidths[scaledWidths.length - 1] += usableWidth - scaledTotal;
188
231
  for (let c = 0; c < colCount; c++) {
189
- const contentWidth = maxLengths[c] * PT_PER_CHAR + PADDING_PER_COL;
190
- const maxWidth = USABLE_PAGE_WIDTH * MAX_COL_RATIO;
191
- const width = Math.min(Math.max(contentWidth, 50), maxWidth);
192
232
  styleRequests.push({
193
233
  updateTableColumnProperties: {
194
234
  tableStartLocation: { index: table2.startIndex },
195
235
  columnIndices: [c],
196
236
  tableColumnProperties: {
197
237
  widthType: 'FIXED_WIDTH',
198
- width: { magnitude: width, unit: 'PT' },
238
+ width: { magnitude: scaledWidths[c], unit: 'PT' },
199
239
  },
200
240
  fields: 'widthType,width',
201
241
  },
202
242
  });
203
243
  }
204
244
  }
205
- // Apply all styling
206
245
  if (styleRequests.length > 0) {
207
246
  await docs.documents.batchUpdate({
208
247
  documentId: args.documentId,
@@ -225,7 +264,6 @@ function resolveStyle(explicit) {
225
264
  let headerBgRgb = DEFAULT_HEADER_BG;
226
265
  let headerTextRgb = DEFAULT_HEADER_TEXT;
227
266
  let cellPadding = DEFAULT_PADDING;
228
- // Try active preset
229
267
  try {
230
268
  const { preset } = getPreset();
231
269
  if (preset.table?.headerBackground)
@@ -238,7 +276,6 @@ function resolveStyle(explicit) {
238
276
  catch {
239
277
  // No active preset, use defaults
240
278
  }
241
- // Explicit overrides
242
279
  if (explicit?.headerBackground)
243
280
  headerBgRgb = hexToRgb(explicit.headerBackground);
244
281
  if (explicit?.headerTextColor)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdocs-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Open-source MCP server for Google Docs and Sheets. Self-hosted, local OAuth, no third-party token storage.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",