星期五, 1月 30, 2026

[GAS] Google Slide 轉圖檔

透過 Presentations.Pages.getThumbnail 把 Google Slide 轉成圖檔,精確說是把 Slide 的每一頁轉成縮圖

Request

參數 說明
presentationId Slide ID
pageObjectId Slide 內每頁投影片 ID
thumbnailProperties 縮圖屬性
mimeType 未指定的話,預設為 PNG
thumbnailSize 未指定的話,伺服器會選擇圖片的預設大小。有 LARGE (1600 像素)、MEDIUM (800 像素)、SMALL (200 像素) 可以選擇


Response

參數 說明
width 縮圖圖片的正向寬度 (以像素為單位)
height 縮圖圖片的正向高度 (以像素為單位)
contentUrl
縮圖伺服器網址,該縮圖只會留存 30 分鐘且只有請求者可以存取該圖片


限制

IntelliSense 或是文件上都會提到 Presentations.Pages.getThumbnail 是高成本讀取操作,在 Usage limits 有特別列出存取限制

限制 次數 說明
Per minute per project 300 次/分鐘 所有執行該 Script 使用者加總次數,每分鐘不能超過 300 次
Per minute per user per project 60 次/分鐘 一位使用者每分鐘最多只能請求 60 次


gs Code

使用 Presentations.Pages.getThumbnail 必須在服務內加入 Goolge Slides API 且產生的 Slide 每頁縮圖會存放在伺服器上,必須透過 response.contentUrl 取回圖片

function runSlideExportAndShare() {

  // 從 PropertiesService 取出 presentationId 和 folderId
  const scriptProperties = PropertiesService.getScriptProperties();
  const properties = scriptProperties.getProperties();
  const presentationId = properties["PresentationID"];
  const folderId = GetProperties("FolderID");

  convertSlidesToImagesAndShare(presentationId, folderId);
}

/**
 * 將 Google Slide 內的每一頁轉換為圖片,存儲於 Google Drive 上,並將圖片設定為「知道連結者皆可檢視」
 * * @param {string} presentationId 簡報 ID
 * @param {string} folderId 圖片存放資料夾 ID
 */
function convertSlidesToImagesAndShare(presentationId, folderId) {

  const presentation = SlidesApp.openById(presentationId);
  const slides = presentation.getSlides();
  const folder = DriveApp.getFolderById(folderId);

  slides.forEach((slide, index) => {
    const pageObjectId = slide.getObjectId();

    // 呼叫 Slides API 取得縮圖 URL
    const thumbnailResponse = Slides.Presentations.Pages.getThumbnail(
      presentationId,
      pageObjectId,
      {
        'thumbnailProperties.mimeType': 'PNG',
        'thumbnailProperties.thumbnailSize': 'LARGE'
      }

    );

    const imageUrl = thumbnailResponse.contentUrl;
    const imageName = `Slide_${index + 1}.png`;

    try {
      // 透過 UrlFetchApp 取回圖片
      const response = UrlFetchApp.fetch(imageUrl);
      const blob = response.getBlob().setName(imageName);

      // 在指定資料夾建立檔案
      const file = folder.createFile(blob);

      // 設定權限:知道連結的任何人 (ANYONE_WITH_LINK) 都可以檢視 (VIEW)
      file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);

    } catch (e) {
      console.error(`處理第 ${index + 1} 頁時發生錯誤: ${e.message}`);
    }
  });
}

執行結果

Slide2Imge 內有兩頁投影片,轉出兩張圖片來

星期二, 1月 27, 2026

[GAS] 資料庫 (Library)

Google Apps Script 寫多會遇上不同專案會需要使用相同功能,例如 LineBot 發訊息通知,會希望把功能模組化來引用,在 [部屬] 功能上有 [資料庫 (library)] 來達到模組化效果,除了跨專案外,也可以跨帳號使用

Library 在 [部屬] 內的中文翻譯是資料庫,但官方文件上翻譯為程序庫,該筆記一律以 Library 來記錄

Library 範例 Code

在 JTLibrary 的 gs Code 內以格式化日期當成說明範例,筆記重點為
  • JSDoc 註解:引用該 function 會有對應說明
  • private function:function 名稱最後加上下底線 (_),外部就無法進行呼叫
/**
 * 格式化日期
 * @param {Date} date 
 * @return {string} 格式化後的字串 (yyyy-MM-dd HH:mm:ss)
 */
function formatCurrentDate(date = new Date()) {
  return Utilities.formatDate(date, Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
}

/**
 * private function,外部 Project 無法呼叫
 */
function internalHelper_() {
  console.log("該 function 僅內部使用");
}

部屬 Library

JTLibrary => 部屬按鈕 => 新增部屬作業 => 選取類型選擇 [資料庫],專案名稱即為 Library 預設名稱
加入 Library

JTLibrary 專案 => 選項 => ID,可以查到該 [指令碼 ID (Script ID)],要特別注意,該 ID 不是上圖 [部屬作業 ID] ,一開始搞錯 ID,一直無法引用

建立新 JTLibraryAction 專案 => 資料庫 => 點選右側加號 (+)

輸入 [指令碼 ID (Sceipt ID)] 後按下 [查詢] 按鈕,有存取權限情況下就會出現
  1. 已成功找到程式庫 JTLibrary 訊息
  2. 可用版本:可透過下拉選單決定要使用哪一個版本
  3. ID:預設為 Library 名稱,萬一該名稱不符合使用習慣,可以直接在這邊更改,不影響引用,事後修改該 ID 名稱的話,gs Code 裡面是不會自動一併修改,要手動修正為新 ID 名稱
存取權限

引用 Library 時萬一 [指令碼 ID (Script ID)] 錯誤或是沒有存取權限,會出現下圖錯誤訊息
授與權限方式即為分享該 Google Apps Script 專案,有兩種方式
  1. 直接把特定使用者、群組加入
  2. 把一般存取權設定為 [知道連結的任何人]
上述兩者都給 [檢視者] 即可,下圖是採取設定為 [知道連結的任何人] 且為 [檢視者]
呼叫 Library 內 function

在 JTLibraryAction 專案內的 gs Code 內輸入時,不會出現 private function internalHelper_

輸入 formatCurrentDate 時,因為有設定 JSDoc 所以會出現文字說明

實際執行
最新程式碼快照 (開發人員模式)

Library 內有個特殊版控版本 - 最新程式碼快照 (開發人員模式) Head (Development mode),不需要部屬出去就可以進行測試使用
刻意在 formatCurrentDate 內呼叫 internalHelper_ function 並只儲存專案未部屬
function formatCurrentDate(date = new Date()) {

  // 新增:呼叫 private function
  internalHelper_();

  return Utilities.formatDate(date, Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
}
實際執行
版本選擇

把 JTLibrary 部屬為新版本,可以在下圖中 [進行中] 看見兩個版本
JTLibraryAction Project 並不會自動套用最新 JTLibrary 部屬版本,是否需要進行升級就看個人需求
JSDoc 文件

JSDoc 說明文件除了 Code 輸入時可以看到,在 Library 內也提供一覽表可以參考

會用網頁形式來呈現文字說明


官方文件 Libraries 內注意事項

執行速度,直接把 Code 放在專案內比呼叫 library 來的快
Warning: A script that uses a library doesn't run as quickly as it would if all the code were contained within a single script project. Although libraries can make development and maintenance more convenient, use them sparingly in projects where speed is critical. Because of this issue, library use should be limited in add-ons.

library 命名,萬一已經存在同名 library,例如也取名 MailApp 的話,library 會覆蓋
If you use an identifier name that matches the name of an already existing service, such as MailApp, or a previously added library, then the library you have added most recently overrides the existing service or library.

星期五, 1月 16, 2026

[OneNote] 快捷鍵 (Accelerator Keys)

使用畫面剪輯功能要把截圖直接放進 OneNote 內,常常要去點擊按鈕才能使用,研究是否有快捷鍵可以直接使用,發現 OneNote 上快捷鍵是可以在軟體本身上查詢,以畫面剪輯功能為紀錄,該筆記使用環境為
  • Windows 11 24H2 26100.7623
  • OneNote 2016 桌機板
Step1:alt 按鍵

按下 alt 按鍵會顯示上方選單的提示字母
Step2:[插入] tab 標示為 N

按 N 進入 [插入] tab 並顯示各功能提示字母
Step3:[畫面剪輯] 功能表示為 R

按 R 觸發畫面剪輯功能,OneNote 視窗最小化,畫面會變成灰色,選擇要擷取畫面會直接插入筆記中

以該範例來說快捷鍵是 alt => n => r,有其他需求依循以上步驟就能找到 OneNote 畫面上的功能快捷鍵

星期六, 1月 10, 2026

[C#] DataGridViewDisableButtonColumn

把該筆記 [C#] 停用 Windows Form DataGridView 控制項按鈕資料行中的按鈕 功能應用在實務環境上,發現還有些小地方還可以改善

DataGridViewDisableButtonCell

該筆記重點
  • 重新繪製 Button 時間點:之前是在 AP 端設定完成後要手動觸發 InvalidateColumn(),現在把繪製觸發點改在 Property 內,只要設定屬性就會立刻重新繪製,且使用 InvalidateCell() 只重新繪製該 cell,縮小繪製範圍
  • 滑鼠外觀強化:當屬性為 false 時,滑鼠指標要變成禁止符號,增強使用者體驗
  • Disable 繪製時 TextFormatFlags 設定:根據 DataGridViewContentAlignment 來產生對應 TextFormatFlags
public class DataGridViewDisableButtonCell : DataGridViewButtonCell
{
    private bool _cellEnabled = true;
    private bool _cellVisible = true;

    public bool CellEnabled
    {
        get => _cellEnabled;
        set
        {
            if (_cellEnabled != value)
            {
                _cellEnabled = value;
                DataGridView?.InvalidateCell(ColumnIndex, RowIndex);
            }
        }
    }

    public bool CellVisible
    {
        get => _cellVisible;
        set
        {
            if (_cellVisible != value)
            {
                _cellVisible = value;
                DataGridView?.InvalidateCell(ColumnIndex, RowIndex);
            }
        }
    }

    public DataGridViewDisableButtonCell()
    {
        _cellEnabled = true;
        _cellVisible = true;           
    }

    public override object Clone()
    {
        var cell = (DataGridViewDisableButtonCell)base.Clone();
        cell.CellEnabled = this.CellEnabled;
        cell.CellVisible = this.CellVisible;
        return cell;
    }

    // --- 游標指標 ---

    protected override void OnMouseEnter(int rowIndex)
    {
        base.OnMouseEnter(rowIndex);

        if ((!_cellEnabled || !_cellVisible) && DataGridView != null)
        {
            DataGridView.Cursor = Cursors.No;
        }
    }

    protected override void OnMouseMove(DataGridViewCellMouseEventArgs e)
    {
        base.OnMouseMove(e);

        if ((!_cellEnabled || !_cellVisible) && DataGridView != null && DataGridView.Cursor != Cursors.No)
        {
            DataGridView.Cursor = Cursors.No;
        }
    }

    protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object? value, object? formattedValue, string? errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts)
    {
        // Visible = false 只繪製背景
        if (!_cellVisible)
        {
            if (paintParts.HasFlag(DataGridViewPaintParts.Background))
            {
                using var backBrush = new SolidBrush(cellStyle.BackColor);
                graphics.FillRectangle(backBrush, cellBounds);
            }
            return;
        }

        // Enabled = true 使用預設繪製方式,確保樣式一致
        if (_cellEnabled)
        {
            base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts);
            return;
        }

        // --- 自定義 Disabled 繪圖邏輯 ---

        // 背景
        if (paintParts.HasFlag(DataGridViewPaintParts.Background))
        {
            using var backBrush = new SolidBrush(cellStyle.BackColor);
            graphics.FillRectangle(backBrush, cellBounds);
        }

        // 邊框
        if (paintParts.HasFlag(DataGridViewPaintParts.Border))
        {
            PaintBorder(graphics, clipBounds, cellBounds, cellStyle, advancedBorderStyle);
        }

        // 計算按鈕區域
        Rectangle buttonArea = cellBounds;
        Rectangle buttonAdjustment = BorderWidths(advancedBorderStyle);
        buttonArea.X += buttonAdjustment.X;
        buttonArea.Y += buttonAdjustment.Y;
        buttonArea.Height -= buttonAdjustment.Height;
        buttonArea.Width -= buttonAdjustment.Width;

        // 按鈕本身 (Disabled 樣式)
        ButtonRenderer.DrawButton(graphics, buttonArea, PushButtonState.Disabled);

        // 文字
        if (formattedValue is string text && !string.IsNullOrEmpty(text))
        {
            TextFormatFlags flags = GetTextFormatFlags(cellStyle.Alignment);

            TextRenderer.DrawText(graphics,
                text,
                cellStyle.Font,
                buttonArea,
                SystemColors.GrayText,
                flags);
        }
    }

    private TextFormatFlags GetTextFormatFlags(DataGridViewContentAlignment alignment)
    {
        // WordBreak: 如果文字太長,允許自動換行
        // PreserveGraphicsClipping: 確保文字不會畫到格子外面去 (效能與安全性)
        TextFormatFlags flags = TextFormatFlags.WordBreak | TextFormatFlags.PreserveGraphicsClipping;

        // 水平位置
        if (alignment.ToString().Contains("Center"))
            flags |= TextFormatFlags.HorizontalCenter; // 加上「水平置中」標籤
        else if (alignment.ToString().Contains("Right"))
            flags |= TextFormatFlags.Right;            // 加上「靠右」標籤
        else
            flags |= TextFormatFlags.Left;             // 預設「靠左」

        // 垂直位置
        if (alignment.ToString().Contains("Top"))
            flags |= TextFormatFlags.Top;              // 加上「靠上」標籤
        else if (alignment.ToString().Contains("Bottom"))
            flags |= TextFormatFlags.Bottom;           // 加上「靠下」標籤
        else
            flags |= TextFormatFlags.VerticalCenter;   // 預設「垂直置中」

        return flags;
    }
}
DataGridViewDisableButtonColumn

當希望 ButtonColumn.Text 文字可以全部顯示在按鈕文字上,要開啟 UseColumnTextForButtonValue 屬性,官方文件說明
By default, the DataGridViewCell.FormattedValue of a button cell is displayed as the text on the button. The UseColumnTextForButtonValue property allows you to either set the button text for each cell, or to use the Text property value for all of the button cells.

Getting or setting this property gets or sets the UseColumnTextForButtonValue property of the object returned by the CellTemplate property. Setting this property also sets the UseColumnTextForButtonValue property of every cell in the column and refreshes the column display. To override the specified value for individual cells, set the cell values after you set the column value.
namespace DataGridViewDisableButtonColumnSample
{
    public class DataGridViewDisableButtonColumn : DataGridViewButtonColumn
    {
        public DataGridViewDisableButtonColumn()
        {
            CellTemplate = new DataGridViewDisableButtonCell();
            
            // 預設值為 false,當希望 DataGridViewColumn.Text 設定值,直接顯示在 Button.Value 上時要開啟
            UseColumnTextForButtonValue = false;             
        }
    }
}

主程式

原本是透過 CellClick 事件來顯示,現在改為使用 CellContentClick 事件,縮小事件觸發範圍
namespace DataGridViewDisableButtonColumnSample
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            dataGridView1.AutoSize = true;
            dataGridView1.AllowUserToAddRows = false;
            dataGridView1.ColumnHeadersDefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleCenter;

            dataGridView1.RowCount = 8;
            for (int i = 0; i < dataGridView1.RowCount; i++)
                dataGridView1.Rows[i].Cells[ColButtonEnabled.Index].Value = $"Button{i}";

            dataGridView1.CellContentClick += DataGridView1_CellContentClick;
            dataGridView1.CellValueChanged += DataGridView1_CellValueChanged;
            dataGridView1.CurrentCellDirtyStateChanged += DataGridView1_CurrentCellDirtyStateChanged;
        }

        private void DataGridView1_CellContentClick(object? sender, DataGridViewCellEventArgs e)
        {
            if (e.ColumnIndex != ColButtonEnabled.Index)
                return;

            DataGridViewDisableButtonCell buttonEnabled = (DataGridViewDisableButtonCell)dataGridView1.Rows[e.RowIndex].Cells[ColButtonEnabled.Index];
            if (buttonEnabled.CellEnabled == false)
                return;

            MessageBox.Show($"{buttonEnabled.Value} 是 Enabled");
        }

        private void DataGridView1_CurrentCellDirtyStateChanged(object? sender, EventArgs e)
        {
            if (dataGridView1.IsCurrentCellDirty &&
                dataGridView1.CurrentCell.ColumnIndex == ColChecked.Index)
                // CommitEdit() 會維持在編輯模式,並立即觸發 CellValueChanged 事件
                dataGridView1.CommitEdit(DataGridViewDataErrorContexts.Commit);
        }

        private void DataGridView1_CellValueChanged(object? sender, DataGridViewCellEventArgs e)
        {
            if (e.ColumnIndex != ColChecked.Index)
                return;

            bool value = (bool)dataGridView1.Rows[e.RowIndex].Cells[ColChecked.Index].Value;

            DataGridViewDisableButtonCell buttonEnabled = (DataGridViewDisableButtonCell)dataGridView1.Rows[e.RowIndex].Cells[ColButtonEnabled.Index];
            buttonEnabled.CellEnabled = !value;

            DataGridViewDisableButtonCell buttonVisible = (DataGridViewDisableButtonCell)dataGridView1.Rows[e.RowIndex].Cells[ColButtonVisible.Index];
            buttonVisible.CellVisible = !value;
        }
    }
}

星期四, 1月 08, 2026

[JS] 全域變數宣告

透過 Vibe Coding 產出的 GAS Code 要複製進線上 IDE 執行時發現錯誤,錯誤訊息如下
SyntaxError: Identifier '變數名稱' has already been declared
Root Cause 是把 gas Code 拆成多個檔案,Code 複製進錯的檔案內造成,但直覺全域變數不是會同名覆蓋,理論上要可以執行才是,該 case 竟然會拋出錯誤,開兩個 gs 檔案來測試並筆記
測試一:使用 var 進行宣告

Code.gs 檔案
var declareByVar = "從 Code.gs 內指定";

function GlobalVariableDemo() {
  console.log(declareByVar);
}
Code2.gs 檔案
var declareByVar = "從 Code2.gs 內指定";
實際執行
測試二:使用 let 進行宣告

Code.gs 檔案
let declareByLet = "從 Code.gs 內指定";

function GlobalVariableDemo() {
  console.log(declareByLet);
}
Code2.gs 檔案
let declareByLet = "從 Code2.gs 內指定";
實際執行
基本就不要再用 var 宣告就是